From 3039083bd99a356dcd2295555f64b1bd62f7a5ac Mon Sep 17 00:00:00 2001 From: zmeyer44 Date: Fri, 20 Oct 2023 15:26:55 -0400 Subject: [PATCH] adding subs --- .../[npub]/_components/MySubscription.tsx | 44 ++++ app/(app)/(profile)/[npub]/page.tsx | 58 +++-- app/(app)/_layout/components/AuthActions.tsx | 3 +- app/(app)/_layout/index.tsx | 2 +- app/(app)/app/_sections/BecomeACreator.tsx | 12 +- app/(app)/list/[naddr]/_components/Header.tsx | 11 +- app/(app)/sub/[naddr]/_components/Header.tsx | 224 ++++++++++++++++++ .../sub/[naddr]/_components/ProfileInfo.tsx | 44 ++++ app/(app)/sub/[naddr]/page.tsx | 71 ++++++ app/(landing)/_layout/index.tsx | 2 +- app/_providers/signer/index.tsx | 17 +- components/KindCard/1.tsx | 5 +- components/KindCard/30023.tsx | 4 +- components/KindCard/30311.tsx | 4 +- components/KindCard/3745.tsx | 69 +++++- components/KindCard/components/Container.tsx | 20 +- .../KindCard/components/ProfileHeader.tsx | 6 +- components/KindCard/default.tsx | 4 +- components/KindCard/index.tsx | 4 +- components/KindCard/loading.tsx | 3 +- components/Modals/CreateList.tsx | 2 +- components/Modals/CreateSubscriptionTier.tsx | 134 +++++++++++ components/Modals/NewEvent.tsx | 1 + components/Modals/ShortTextNote.tsx | 112 ++++----- components/Modals/ShortTextNoteOnList.tsx | 161 +++++++++++++ components/SubscriptionCard/index.tsx | 107 +++++++-- lib/actions/create.ts | 20 +- lib/hooks/useCurrentUser.ts | 25 +- lib/hooks/useSubscriptions.ts | 35 +++ lib/stores/currentUser.ts | 6 +- lib/stores/subscriptions.ts | 15 ++ 31 files changed, 1081 insertions(+), 144 deletions(-) create mode 100644 app/(app)/(profile)/[npub]/_components/MySubscription.tsx create mode 100644 app/(app)/sub/[naddr]/_components/Header.tsx create mode 100644 app/(app)/sub/[naddr]/_components/ProfileInfo.tsx create mode 100644 app/(app)/sub/[naddr]/page.tsx create mode 100644 components/Modals/CreateSubscriptionTier.tsx create mode 100644 components/Modals/ShortTextNoteOnList.tsx create mode 100644 lib/hooks/useSubscriptions.ts create mode 100644 lib/stores/subscriptions.ts diff --git a/app/(app)/(profile)/[npub]/_components/MySubscription.tsx b/app/(app)/(profile)/[npub]/_components/MySubscription.tsx new file mode 100644 index 0000000..a90c4b0 --- /dev/null +++ b/app/(app)/(profile)/[npub]/_components/MySubscription.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import SubscriptionCard from "@/components/SubscriptionCard"; +import useCurrentUser from "@/lib/hooks/useCurrentUser"; +import { useNDK } from "@/app/_providers/ndk"; +import { type NDKKind, type NDKEvent } from "@nostr-dev-kit/ndk"; +import { getTagsValues } from "@/lib/nostr/utils"; + +type MySubscription = { + pubkey: string; +}; + +export default function MySubscription({ pubkey }: MySubscription) { + const { fetchEvents } = useNDK(); + const { currentUser, mySubscription, follows } = useCurrentUser(); + const [subscriptionTiers, setSubscriptionTiers] = useState([]); + + useEffect(() => { + void handleFetchSubscriptionTiers(); + }, [pubkey]); + + async function handleFetchSubscriptionTiers() { + try { + const events = await fetchEvents({ + kinds: [30044 as NDKKind], + authors: [pubkey], + }); + setSubscriptionTiers(events); + } catch (err) { + console.log("error", err); + } + } + if (!subscriptionTiers.length) return null; + return ( + <> + {subscriptionTiers.map((e) => { + const isMember = + currentUser && + getTagsValues("p", e.tags).includes(currentUser.pubkey); + if (isMember) return null; + return ; + })} + + ); +} diff --git a/app/(app)/(profile)/[npub]/page.tsx b/app/(app)/(profile)/[npub]/page.tsx index c09cbe5..4087a3b 100644 --- a/app/(app)/(profile)/[npub]/page.tsx +++ b/app/(app)/(profile)/[npub]/page.tsx @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; +import dynamic from "next/dynamic"; import Image from "next/image"; import { Button } from "@/components/ui/button"; -import SubscriptionCard from "@/components/SubscriptionCard"; import { HiCheckBadge } from "react-icons/hi2"; import Tabs from "@/components/Tabs"; import useProfile from "@/lib/hooks/useProfile"; @@ -11,9 +11,23 @@ import ProfileFeed from "./_components/Feed"; import Subscriptions from "./_components/Subscriptions"; import { nip19 } from "nostr-tools"; import useLists from "@/lib/hooks/useLists"; -import EditProfileModal from "@/components/Modals/EditProfile"; import { useModal } from "@/app/_providers/modal/provider"; import useCurrentUser from "@/lib/hooks/useCurrentUser"; +import { NDKUser } from "@nostr-dev-kit/ndk"; +import MySubscription from "./_components/MySubscription"; +const EditProfileModal = dynamic( + () => import("@/components/Modals/EditProfile"), + { + ssr: false, + }, +); +const CreateSubecriptionTierModal = dynamic( + () => import("@/components/Modals/CreateSubscriptionTier"), + { + ssr: false, + }, +); + export default function ProfilePage({ params: { npub }, }: { @@ -22,7 +36,7 @@ export default function ProfilePage({ }; }) { const modal = useModal(); - const { currentUser, follows } = useCurrentUser(); + const { currentUser, mySubscription, follows } = useCurrentUser(); const [activeTab, setActiveTab] = useState("feed"); const { type, data } = nip19.decode(npub); @@ -31,7 +45,6 @@ export default function ProfilePage({ } const pubkey = data.toString(); const { profile } = useProfile(pubkey); - const { init, lists } = useLists(); return (
@@ -74,6 +87,14 @@ export default function ProfilePage({ )}
+ {currentUser?.pubkey === pubkey && !mySubscription && ( + + )} {currentUser?.pubkey === pubkey && ( )} - {!follows.includes(pubkey) && ( - - )} + {currentUser && + !Array.from(follows).find((i) => i.pubkey === pubkey) && ( + + )}
@@ -123,9 +151,7 @@ export default function ProfilePage({
- {/* {[].map((e) => ( - - ))} */} +
import("@/components/Modals/Login"), { export default function AuthActions() { const router = useRouter(); const modal = useModal(); - const { currentUser, logout, attemptLogin } = useCurrentUser(); + const { currentUser, logout, attemptLogin, initSubscriptions } = + useCurrentUser(); const { ndk } = useNDK(); useKeyboardShortcut(["shift", "ctrl", "u"], () => { diff --git a/app/(app)/_layout/index.tsx b/app/(app)/_layout/index.tsx index fb75fa7..bd4ed1e 100644 --- a/app/(app)/_layout/index.tsx +++ b/app/(app)/_layout/index.tsx @@ -20,7 +20,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { {/* Sidebar */}
-
{children}
+
{children}
{/* Mobile Banner */} diff --git a/app/(app)/app/_sections/BecomeACreator.tsx b/app/(app)/app/_sections/BecomeACreator.tsx index 94716ef..473e8a6 100644 --- a/app/(app)/app/_sections/BecomeACreator.tsx +++ b/app/(app)/app/_sections/BecomeACreator.tsx @@ -1,7 +1,11 @@ +"use client"; import Image from "next/image"; import { Button } from "@/components/ui/button"; +import { useModal } from "@/app/_providers/modal/provider"; +import CreateSubscriptionTier from "@/components/Modals/CreateSubscriptionTier"; export default function BecomeACreator() { + const modal = useModal(); return (
@@ -19,10 +23,12 @@ export default function BecomeACreator() { Start earning on Nostr
- Start earning bitcoin from your content. Create lists that for fans - can subscribe to! + Create a subscrition tier so you can start offering your users + access to private content!
- +
diff --git a/app/(app)/list/[naddr]/_components/Header.tsx b/app/(app)/list/[naddr]/_components/Header.tsx index 662794e..ee3f1a6 100644 --- a/app/(app)/list/[naddr]/_components/Header.tsx +++ b/app/(app)/list/[naddr]/_components/Header.tsx @@ -23,9 +23,12 @@ import { formatDate } from "@/lib/utils/dates"; const EditListModal = dynamic(() => import("@/components/Modals/EditList"), { ssr: false, }); -const CreateEventModal = dynamic(() => import("@/components/Modals/NewEvent"), { - ssr: false, -}); +const CreateListEvent = dynamic( + () => import("@/components/Modals/ShortTextNoteOnList"), + { + ssr: false, + }, +); const ConfirmModal = dynamic(() => import("@/components/Modals/Confirm"), { ssr: false, }); @@ -151,7 +154,7 @@ export default function Header({ event }: { event: NDKEvent }) {
{!!currentUser && currentUser.pubkey === pubkey && ( <> - + + {/* */} + + )} + {subscriptionsEnabled && + !isMember && + (hasValidPayment ? ( + + ) : ( + + ))} +
+
+ +
+ {!!description && ( +

+ {description} +

+ )} +
+ + + ); +} diff --git a/app/(app)/sub/[naddr]/_components/ProfileInfo.tsx b/app/(app)/sub/[naddr]/_components/ProfileInfo.tsx new file mode 100644 index 0000000..8595e7d --- /dev/null +++ b/app/(app)/sub/[naddr]/_components/ProfileInfo.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import useProfile from "@/lib/hooks/useProfile"; +import { nip19 } from "nostr-tools"; +import { getTwoLetters, getNameToShow } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; +import { HiMiniChevronRight, HiCheckBadge } from "react-icons/hi2"; + +type ProfileInfoProps = { + pubkey: string; +}; +export default function ProfileInfo({ pubkey }: ProfileInfoProps) { + const { profile } = useProfile(pubkey); + const npub = nip19.npubEncode(pubkey); + return ( + + + + + {getTwoLetters({ npub, profile })} + + +
+ {getNameToShow({ npub, profile })} + {!!profile?.nip05 && } +
+ + + ); +} + +export function LoadingProfileInfo() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/app/(app)/sub/[naddr]/page.tsx b/app/(app)/sub/[naddr]/page.tsx new file mode 100644 index 0000000..9c7d99b --- /dev/null +++ b/app/(app)/sub/[naddr]/page.tsx @@ -0,0 +1,71 @@ +"use client"; +import { useState } from "react"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import SubscriptionCard from "@/components/SubscriptionCard"; +import { HiCheckBadge } from "react-icons/hi2"; +import Tabs from "@/components/Tabs"; +import useProfile from "@/lib/hooks/useProfile"; +import { getTwoLetters, truncateText } from "@/lib/utils"; +import { nip19 } from "nostr-tools"; +import useEvents from "@/lib/hooks/useEvents"; +import Spinner from "@/components/spinner"; +import { getTagValues, getTagsValues } from "@/lib/nostr/utils"; +import ProfileInfo from "./_components/ProfileInfo"; +import Feed from "@/containers/Feed"; +import useCurrentUser from "@/lib/hooks/useCurrentUser"; +import Header from "./_components/Header"; + +export default function ListPage({ + params: { naddr }, +}: { + params: { + naddr: string; + }; +}) { + const { type, data } = nip19.decode(naddr); + if (type !== "naddr") { + throw new Error("Invalid list"); + } + const { identifier, kind, pubkey } = data; + const { events } = useEvents({ + filter: { + authors: [pubkey], + kinds: [kind], + ["#d"]: [identifier], + limit: 1, + }, + }); + const event = events[0]; + + if (!event) { + return ( +
+ +
+ ); + } + const noteIds = getTagsValues("e", event.tags).filter(Boolean); + console.log("notes", noteIds); + console.log("tags", event.tags); + + return ( +
+
+
+
+ ( +
+

No notes yet

+
+ )} + /> +
+
+
+ ); +} diff --git a/app/(landing)/_layout/index.tsx b/app/(landing)/_layout/index.tsx index 7ee1bac..009cca0 100644 --- a/app/(landing)/_layout/index.tsx +++ b/app/(landing)/_layout/index.tsx @@ -3,7 +3,7 @@ import Footer from "./Footer"; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
diff --git a/app/_providers/signer/index.tsx b/app/_providers/signer/index.tsx index db2cd9f..d5cb74c 100644 --- a/app/_providers/signer/index.tsx +++ b/app/_providers/signer/index.tsx @@ -16,6 +16,7 @@ import { } from "@nostr-dev-kit/ndk"; import { useNDK } from "../ndk"; import { log } from "@/lib/utils"; +import { getTagValues } from "@/lib/nostr/utils"; export type SignerStoreItem = { signer: NDKPrivateKeySigner; @@ -52,7 +53,10 @@ export default function SignerProvider({ async function getDelegatedSignerName(list: NDKList) { let name = ""; - console.log("getDelegatedSignerName", list); + console.log("getDelegatedSignerName", JSON.stringify(list)); + if (list.kind === 30044) { + return list.title; + } if (!currentUser?.profile) { console.log("fetching user profile"); await currentUser?.fetchProfile(); @@ -64,7 +68,9 @@ export default function SignerProvider({ `${currentUser?.npub.slice(0, 9)}...` }'s `; - return name + list.title ?? "List"; + const suffix = list.kind === 30044 ? "Subscription" : "List"; + + return name + list.title ?? suffix; } async function getSigner(list: NDKList): Promise { @@ -80,7 +86,7 @@ export default function SignerProvider({ }); if (signer) { - log("info", `found a signer for list ${list.title}`, signer.toString()); + log("info", `found a signer for ${list.title}`, JSON.stringify(signer)); item = { signer: signer!, user: await signer.user(), @@ -88,9 +94,10 @@ export default function SignerProvider({ id, }; } else { - log("info", `no signer found for list ${list.title}`); + log("info", `no signer found for ${list.title}`); + signer = NDKPrivateKeySigner.generate(); - log("info", "generated signer", signer.toString()); + log("info", "generated signer", JSON.stringify(signer)); item = { signer, user: await signer.user(), diff --git a/components/KindCard/1.tsx b/components/KindCard/1.tsx index 2e976eb..fcb03b8 100644 --- a/components/KindCard/1.tsx +++ b/components/KindCard/1.tsx @@ -1,20 +1,21 @@ import Container from "./components/Container"; import { CardTitle, CardDescription } from "@/components/ui/card"; -import { type Event } from "nostr-tools"; import { RenderText } from "../TextRendering"; import { getTagsValues } from "@/lib/nostr/utils"; import LinkCard from "@/components/LinkCard"; import { copyText } from "@/lib/utils"; import { nip19 } from "nostr-tools"; import { toast } from "sonner"; +import { type KindCardProps } from "./"; -export default function Kind1(props: Event) { +export default function Kind1(props: KindCardProps) { const { content, pubkey, tags, created_at: createdAt } = props; const r = getTagsValues("r", tags).filter(Boolean); const npub = nip19.npubEncode(pubkey); return ( (); - const { ndk } = useNDK(); + const { ndk, fetchEvents } = useNDK(); useEffect(() => { if (ndk && !fetchingEvent && !decryptedEvent) { void handleFetchEvent(); @@ -27,6 +40,9 @@ export default function Kind3745(props: Event) { }, [ndk]); async function handleFetchEvent() { + if (!ndk) return; + log("func", `handleFetchEvent(${pubkey})`); + setFetchingEvent(true); try { const directMessageEvent = await ndk!.fetchEvent({ @@ -35,16 +51,19 @@ export default function Kind3745(props: Event) { ["#e"]: [id], }); if (directMessageEvent) { + log("info", "direct msg decryption"); + console.log(directMessageEvent); await directMessageEvent.decrypt( new NDKUser({ hexpubkey: pubkey }), ndk!.signer, ); - const passphrase = directMessageEvent.content; - if (!passphrase) { + const passphrase_ = directMessageEvent.content; + if (!passphrase_) { setError("Unable to parse event"); return; } - const decrypedData = await decryptMessage(content, passphrase); + setPassphrase(passphrase_); + const decrypedData = await decryptMessage(content, passphrase_); console.log("Decrypted", decrypedData); const hiddenEvent = EventSchema.safeParse( JSON.parse(decrypedData ?? ""), @@ -57,12 +76,48 @@ export default function Kind3745(props: Event) { } } catch (err) { setError("Unable to parse event"); + console.log("ERROR", err); } finally { setFetchingEvent(false); } } + + async function handleUnlockEvent() { + await unlockEvent(ndk!, props, passphrase); + window.location.reload(); + } + if (decryptedEvent) { - return ; + return ( +
+ + {currentUser?.pubkey === decryptedEvent.pubkey && ( +
+ + + + + + +

Unlock Content

+
+
+
+
+ )} +
+ ); } return ( diff --git a/components/KindCard/components/Container.tsx b/components/KindCard/components/Container.tsx index 0e7fb48..f58ef92 100644 --- a/components/KindCard/components/Container.tsx +++ b/components/KindCard/components/Container.tsx @@ -11,8 +11,9 @@ import { getTagValues, getTagsValues } from "@/lib/nostr/utils"; import Actions from "./Actions"; import Tags from "./Tags"; import DropDownMenu from "@/components/DropDownMenu"; -import { NostrEvent } from "@nostr-dev-kit/ndk"; import { removeDuplicates } from "@/lib/utils"; +import { type KindCardProps } from ".."; + type OptionLink = { href: string; type: "link"; @@ -28,7 +29,7 @@ type Option = { type CreatorCardProps = { children: ReactNode; actionOptions?: Option[]; - event?: NostrEvent; + event?: KindCardProps; }; export default function Container({ @@ -60,20 +61,19 @@ export default function Container({ ); } - const { pubkey, tags, created_at: createdAt } = event; + const { pubkey, tags, created_at: createdAt, locked } = event; const contentTags = removeDuplicates(getTagsValues("t", tags)).filter( Boolean, ); return ( - { - console.log("CLICK IN CONTAINER"); - }} - className="relative flex h-full w-full flex-col overflow-hidden @container" - > + - {pubkey ? : } + {pubkey ? ( + + ) : ( + + )}
{!!createdAt && formatDate(new Date(createdAt * 1000), "MMM Do, h:mm a")} diff --git a/components/KindCard/components/ProfileHeader.tsx b/components/KindCard/components/ProfileHeader.tsx index 20f016a..e43f761 100644 --- a/components/KindCard/components/ProfileHeader.tsx +++ b/components/KindCard/components/ProfileHeader.tsx @@ -1,5 +1,5 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; -import { HiCheckBadge } from "react-icons/hi2"; +import { HiCheckBadge, HiMiniKey } from "react-icons/hi2"; import Link from "next/link"; import useProfile from "@/lib/hooks/useProfile"; import { nip19 } from "nostr-tools"; @@ -8,8 +8,9 @@ import { Skeleton } from "@/components/ui/skeleton"; type ProfileHeaderProps = { pubkey: string; + locked?: boolean; }; -export default function ProfileHeader({ pubkey }: ProfileHeaderProps) { +export default function ProfileHeader({ pubkey, locked }: ProfileHeaderProps) { const { profile } = useProfile(pubkey); const npub = nip19.npubEncode(pubkey); return ( @@ -29,6 +30,7 @@ export default function ProfileHeader({ pubkey }: ProfileHeaderProps) { {!!profile?.nip05 && ( )} + {!!locked && }
{!!profile.nip05 && ( diff --git a/components/KindCard/default.tsx b/components/KindCard/default.tsx index c37d7a1..7c42065 100644 --- a/components/KindCard/default.tsx +++ b/components/KindCard/default.tsx @@ -1,14 +1,14 @@ import Container from "./components/Container"; import { CardTitle, CardDescription } from "@/components/ui/card"; -import { type Event } from "nostr-tools"; import { nip19 } from "nostr-tools"; import { toast } from "sonner"; import { copyText } from "@/lib/utils"; import { RenderText } from "../TextRendering"; import { getTagsValues } from "@/lib/nostr/utils"; import LinkCard from "@/components/LinkCard"; +import { type KindCardProps } from "./"; -export default function KindDefault(props: Event) { +export default function KindDefault(props: KindCardProps) { const { pubkey, created_at: createdAt, tags } = props; const r = getTagsValues("r", tags).filter(Boolean); diff --git a/components/KindCard/index.tsx b/components/KindCard/index.tsx index 8b4b5ef..57a5dcf 100644 --- a/components/KindCard/index.tsx +++ b/components/KindCard/index.tsx @@ -27,7 +27,9 @@ const componentMap: Record> = { 30311: KindCard30311, }; -type KindCardProps = Event; +export type KindCardProps = Event & { + locked?: boolean; +}; export default function KindCard(props: KindCardProps) { const { kind } = props; const KindCard_ = componentMap[kind] ?? KindCardDefault; diff --git a/components/KindCard/loading.tsx b/components/KindCard/loading.tsx index 6eeca52..6a4dcf1 100644 --- a/components/KindCard/loading.tsx +++ b/components/KindCard/loading.tsx @@ -1,7 +1,6 @@ import Container from "./components/Container"; -import { CardTitle, CardDescription } from "@/components/ui/card"; -import { type Event } from "nostr-tools"; import { Skeleton } from "@/components/ui/skeleton"; +import { type KindCardProps } from "./"; export default function KindLoading() { return ( diff --git a/components/Modals/CreateList.tsx b/components/Modals/CreateList.tsx index b9661c0..f047b6c 100644 --- a/components/Modals/CreateList.tsx +++ b/components/Modals/CreateList.tsx @@ -19,7 +19,7 @@ const CreateListSchema = z.object({ title: z.string(), image: z.string().optional(), description: z.string().optional(), - subscriptions: z.boolean(), + subscriptions: z.boolean().optional(), price: z.number().optional(), }); diff --git a/components/Modals/CreateSubscriptionTier.tsx b/components/Modals/CreateSubscriptionTier.tsx new file mode 100644 index 0000000..9735b14 --- /dev/null +++ b/components/Modals/CreateSubscriptionTier.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import FormModal from "./FormModal"; +import { z } from "zod"; +import { useModal } from "@/app/_providers/modal/provider"; +import { toast } from "sonner"; +import { randomId } 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, updateList } from "@/lib/actions/create"; +import { getTagValues } from "@/lib/nostr/utils"; +import { NDKList, NDKUser } from "@nostr-dev-kit/ndk"; +import { saveEphemeralSigner } from "@/lib/actions/ephemeral"; +import { useRouter } from "next/navigation"; +import { log } from "@/lib/utils"; + +const CreateSubscriptionTierSchema = z.object({ + title: z.string(), + image: z.string().optional(), + description: z.string().optional(), + price: z.number().optional(), +}); + +type CreateSubscriptionTierType = z.infer; + +export default function CreateSubscriptionTier() { + const [isLoading, setIsLoading] = useState(false); + const modal = useModal(); + const router = useRouter(); + + const { currentUser, initSubscriptions } = useCurrentUser(); + const { ndk } = useNDK(); + const { getSigner } = useSigner()!; + + async function handleSubmit(data: CreateSubscriptionTierType) { + if (!currentUser) return; + setIsLoading(true); + const random = randomId(); + const tags = [ + ["title", data.title], + ["name", data.title], + ["d", random], + ["p", currentUser!.pubkey, "", "self", "4000000000"], + ]; + + 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.price) { + tags.push(["price", satsToBtc(data.price).toString(), "btc", "year"]); + } + const event = await createEvent(ndk!, { + content: "[]", + kind: 30044, + tags: tags, + }); + if (event) { + 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) => { + log("info", "savedSigner", savedSigner.toString()); + currentUser.follow( + new NDKUser({ + hexpubkey: savedSigner.pubkey, + }), + ); + return updateList(ndk!, event.rawEvent(), [ + ["delegate", savedSigner.pubkey], + ]); + }) + .catch((err) => console.log("Error creating delegate")); + } + // getSubscriptionTiers(currentUser!.hexpubkey); + setIsLoading(false); + if (event) { + initSubscriptions(currentUser.pubkey); + toast.success("Subscription Tier Created!"); + modal?.hide(); + router.push(`/sub/${event.encode()}`); + } else { + toast.error("An error occured"); + } + } + return ( + + ); +} diff --git a/components/Modals/NewEvent.tsx b/components/Modals/NewEvent.tsx index 7f6361e..8ae17df 100644 --- a/components/Modals/NewEvent.tsx +++ b/components/Modals/NewEvent.tsx @@ -12,6 +12,7 @@ import { HiChatBubbleLeftEllipsis, HiBookmarkSquare, HiNewspaper, + HiUserGroup, } from "react-icons/hi2"; import { RiSubtractFill, RiAddFill } from "react-icons/ri"; import { formatCount } from "@/lib/utils"; diff --git a/components/Modals/ShortTextNote.tsx b/components/Modals/ShortTextNote.tsx index 904a04c..4150623 100644 --- a/components/Modals/ShortTextNote.tsx +++ b/components/Modals/ShortTextNote.tsx @@ -13,27 +13,26 @@ import useCurrentUser from "@/lib/hooks/useCurrentUser"; import { saveEphemeralSigner } from "@/lib/actions/ephemeral"; import useLists from "@/lib/hooks/useLists"; import { getUrls } from "@/components/TextRendering"; - +import { log } from "@/lib/utils"; const ShortTextNoteSchema = z.object({ content: z.string(), - list: z.string().optional(), isPrivate: z.boolean().optional(), + subscriptionTiers: z.string().array().optional(), }); type ShortTextNoteType = z.infer; export default function ShortTextNoteModal() { const modal = useModal(); - const { lists, init } = useLists(); const [isLoading, setIsLoading] = useState(false); - const { currentUser } = useCurrentUser(); + const { currentUser, initSubscriptions, mySubscription } = useCurrentUser(); const [sent, setSent] = useState(false); const { ndk } = useNDK(); const { getSigner } = useSigner()!; useEffect(() => { if (currentUser) { - void init(currentUser.pubkey); + void initSubscriptions(currentUser.pubkey); } }, [currentUser]); @@ -54,25 +53,24 @@ export default function ShortTextNoteModal() { } const urls = getUrls(data.content); const urlTags = urls.map((u) => ["r", u]); - if (data.list) { - const list = lists.find((l) => getTagValues("d", l.tags) === data.list); - if (!list) { + if (data.isPrivate) { + if (!mySubscription) { toast.error("No list found"); return; } - let listSigner: SignerStoreItem | undefined = undefined; + let delegateSigner: SignerStoreItem | undefined = undefined; if (data.isPrivate) { - listSigner = await getSigner(list); - if (!listSigner?.signer) { + delegateSigner = await getSigner(mySubscription); + if (!delegateSigner?.signer) { toast.error("Error creating signer"); return; } - if (!listSigner?.saved) { + if (!delegateSigner?.saved) { console.log("Saving delegate..."); - await saveEphemeralSigner(ndk!, listSigner.signer, { - associatedEvent: list, + await saveEphemeralSigner(ndk!, delegateSigner.signer, { + associatedEvent: mySubscription, keyProfile: { - name: listSigner.title, + name: delegateSigner.title, picture: currentUser?.profile?.image, lud06: currentUser?.profile?.lud06, lud16: currentUser?.profile?.lud16, @@ -80,7 +78,11 @@ export default function ShortTextNoteModal() { }); } } - console.log("about to create private event with ", listSigner); + log( + "info", + "about to create private event with ", + JSON.stringify(delegateSigner), + ); const result = await createEventHandler( ndk, { @@ -89,23 +91,19 @@ export default function ShortTextNoteModal() { tags: [...urlTags], }, data.isPrivate, - list, - listSigner?.signer, + mySubscription, + delegateSigner?.signer, ); if (result) { toast.success("Note added!"); modal?.hide(); } } else { - const result = await createEventHandler( - ndk, - { - content: data.content, - kind: 1, - tags: [...urlTags], - }, - data.isPrivate, - ); + const result = await createEventHandler(ndk, { + content: data.content, + kind: 1, + tags: [...urlTags], + }); if (result) { toast.success("Note added!"); modal?.hide(); @@ -122,33 +120,39 @@ export default function ShortTextNoteModal() { type: "text-area", slug: "content", }, - { - label: "Add to list", - type: "select-search", - placeholder: "Search your lists", - slug: "list", - options: lists - .map((l) => { - const title = - getTagValues("title", l.tags) ?? - getTagValues("name", l.tags) ?? - "Untitled"; - const description = getTagValues("description", l.tags); - const value = getTagValues("d", l.tags); - if (!value) return; - return { - label: title, - description, - value: value, - }; - }) - .filter(Boolean), - }, - { - label: "Private", - type: "toggle", - slug: "isPrivate", - }, + ...(mySubscription + ? ([ + { + label: "Subs only", + type: "toggle", + slug: "isPrivate", + }, + ] as const) + : []), + + // { + // label: "Choose Tiers", + // type: "select-search", + // placeholder: "Search your lists", + // slug: "subscriptionTiers", + // options: lists + // .map((l) => { + // const title = + // getTagValues("title", l.tags) ?? + // getTagValues("name", l.tags) ?? + // "Untitled"; + // const description = getTagValues("description", l.tags); + // const value = getTagValues("d", l.tags); + // if (!value) return; + // return { + // label: title, + // description, + // value: value, + // }; + // }) + // .filter(Boolean), + // condition: "subscriptions", + // }, ]} formSchema={ShortTextNoteSchema} onSubmit={handleSubmit} diff --git a/components/Modals/ShortTextNoteOnList.tsx b/components/Modals/ShortTextNoteOnList.tsx new file mode 100644 index 0000000..904a04c --- /dev/null +++ b/components/Modals/ShortTextNoteOnList.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from "react"; +import FormModal from "./FormModal"; +import { z } from "zod"; +import useEvents from "@/lib/hooks/useEvents"; +import { createEventHandler } from "@/lib/actions/create"; +import { unixTimeNowInSeconds } from "@/lib/nostr/dates"; +import { useModal } from "@/app/_providers/modal/provider"; +import { toast } from "sonner"; +import { useNDK } from "@/app/_providers/ndk"; +import { useSigner, type SignerStoreItem } from "@/app/_providers/signer"; +import { getTagValues } from "@/lib/nostr/utils"; +import useCurrentUser from "@/lib/hooks/useCurrentUser"; +import { saveEphemeralSigner } from "@/lib/actions/ephemeral"; +import useLists from "@/lib/hooks/useLists"; +import { getUrls } from "@/components/TextRendering"; + +const ShortTextNoteSchema = z.object({ + content: z.string(), + list: z.string().optional(), + isPrivate: z.boolean().optional(), +}); + +type ShortTextNoteType = z.infer; + +export default function ShortTextNoteModal() { + const modal = useModal(); + const { lists, init } = useLists(); + const [isLoading, setIsLoading] = useState(false); + const { currentUser } = useCurrentUser(); + const [sent, setSent] = useState(false); + const { ndk } = useNDK(); + const { getSigner } = useSigner()!; + + useEffect(() => { + if (currentUser) { + void init(currentUser.pubkey); + } + }, [currentUser]); + + // useEffect(() => { + // if (events.length) { + // console.log("Done!"); + // setIsLoading(false); + // toast.success("List Updated!"); + // modal?.hide(); + // } + // }, [events]); + + async function handleSubmit(data: ShortTextNoteType) { + setIsLoading(true); + if (!ndk) { + toast.error("Error connecting"); + return; + } + const urls = getUrls(data.content); + const urlTags = urls.map((u) => ["r", u]); + if (data.list) { + const list = lists.find((l) => getTagValues("d", l.tags) === data.list); + if (!list) { + toast.error("No list found"); + return; + } + let listSigner: SignerStoreItem | undefined = undefined; + if (data.isPrivate) { + listSigner = await getSigner(list); + if (!listSigner?.signer) { + toast.error("Error creating signer"); + return; + } + if (!listSigner?.saved) { + console.log("Saving delegate..."); + await saveEphemeralSigner(ndk!, listSigner.signer, { + associatedEvent: list, + keyProfile: { + name: listSigner.title, + picture: currentUser?.profile?.image, + lud06: currentUser?.profile?.lud06, + lud16: currentUser?.profile?.lud16, + }, + }); + } + } + console.log("about to create private event with ", listSigner); + const result = await createEventHandler( + ndk, + { + content: data.content, + kind: 1, + tags: [...urlTags], + }, + data.isPrivate, + list, + listSigner?.signer, + ); + if (result) { + toast.success("Note added!"); + modal?.hide(); + } + } else { + const result = await createEventHandler( + ndk, + { + content: data.content, + kind: 1, + tags: [...urlTags], + }, + data.isPrivate, + ); + if (result) { + toast.success("Note added!"); + modal?.hide(); + } + } + } + + return ( + { + const title = + getTagValues("title", l.tags) ?? + getTagValues("name", l.tags) ?? + "Untitled"; + const description = getTagValues("description", l.tags); + const value = getTagValues("d", l.tags); + if (!value) return; + return { + label: title, + description, + value: value, + }; + }) + .filter(Boolean), + }, + { + label: "Private", + type: "toggle", + slug: "isPrivate", + }, + ]} + formSchema={ShortTextNoteSchema} + onSubmit={handleSubmit} + isSubmitting={isLoading} + cta={{ + text: "Publish", + }} + /> + ); +} diff --git a/components/SubscriptionCard/index.tsx b/components/SubscriptionCard/index.tsx index 5590c9a..81a4604 100644 --- a/components/SubscriptionCard/index.tsx +++ b/components/SubscriptionCard/index.tsx @@ -1,4 +1,6 @@ +import { useState, useEffect } from "react"; import Image from "next/image"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, @@ -8,28 +10,79 @@ import { CardTitle, } from "@/components/ui/card"; import { cn } from "@/lib/utils"; -import { HiOutlineCheckBadge } from "react-icons/hi2"; +import { NDKEvent, NDKUser, NostrEvent } from "@nostr-dev-kit/ndk"; +import { useNDK } from "@/app/_providers/ndk"; +import useCurrentUser from "@/lib/hooks/useCurrentUser"; +import { toast } from "sonner"; +import { getTagValues, getTagsValues } from "@/lib/nostr/utils"; +import { sendZap, checkPayment } from "@/lib/actions/zap"; +import { btcToSats, formatNumber } from "@/lib/utils"; -type SubscriptionCardProps = { - id: string; - title: string; - picture: string; - description: string; - tags: string[]; -}; -export default function SubscriptionCard({ - picture, - title, - tags, - description, -}: SubscriptionCardProps) { +export default function SubscriptionCard({ event }: { event: NDKEvent }) { + const { currentUser } = useCurrentUser(); + const { ndk } = useNDK(); + const [checkingPayment, setCheckingPayment] = useState(false); + const [hasValidPayment, setHasValidPayment] = useState(false); + const { tags } = event; + const rawEvent = event.rawEvent(); + const title = getTagValues("title", tags) ?? getTagValues("name", tags) ?? ""; + const image = + getTagValues("image", tags) ?? getTagValues("picture", tags) ?? ""; + const description = + getTagValues("description", tags) ?? getTagValues("summary", tags) ?? ""; + const delegate = getTagValues("delegate", tags); + const priceInBTC = parseFloat(getTagValues("price", rawEvent.tags) ?? "0"); + + async function handleSubscribe() { + try { + if (!currentUser) return; + await currentUser.follow( + new NDKUser({ + hexpubkey: delegate, + }), + ); + const result = await sendZap( + ndk!, + btcToSats(priceInBTC), + rawEvent, + `Access payment: ${title}`, + ); + toast.success("Payment Sent!"); + void handleCheckPayment(); + } catch (err) { + console.log("error sending zap", err); + } finally { + } + } + + async function handleCheckPayment() { + if (!event || !currentUser || !ndk) return; + setCheckingPayment(true); + console.log("Checking payment"); + try { + const result = await checkPayment( + ndk, + event.tagId(), + currentUser.pubkey, + rawEvent, + ); + console.log("Payment result", result); + if (result) { + setHasValidPayment(true); + } + } catch (err) { + console.log("error sending zap", err); + } finally { + setCheckingPayment(false); + } + } return (
{title} - - + {hasValidPayment ? ( + + ) : ( + <> + + + + + + )}
diff --git a/lib/actions/create.ts b/lib/actions/create.ts index f4218b3..6a491b7 100644 --- a/lib/actions/create.ts +++ b/lib/actions/create.ts @@ -7,7 +7,12 @@ import NDK, { type NostrEvent, NDKUser, } from "@nostr-dev-kit/ndk"; -import { generateRandomString, encryptMessage, randomId } from "@/lib/nostr"; +import { EventSchema } from "@/types"; +import { + generateRandomString, + encryptMessage, + decryptMessage, +} from "@/lib/nostr"; import { unixTimeNowInSeconds } from "@/lib/nostr/dates"; import { getTagsValues } from "@/lib/nostr/utils"; import { log } from "@/lib/utils"; @@ -262,3 +267,16 @@ export async function updateList( tags: tags.filter(([_, value]) => value !== undefined), }); } + +export async function unlockEvent( + ndk: NDK, + event: NostrEvent, + passphrase: string, +) { + const decrypedData = await decryptMessage(event.content, passphrase); + const hiddenEvent = EventSchema.parse(JSON.parse(decrypedData ?? "")); + // Create New public event + const publishedEvent = await new NDKEvent(ndk, hiddenEvent).publish(); + await deleteEvent(ndk, [["e", event.id ?? ""]], "Content unlocked"); + return publishedEvent; +} diff --git a/lib/hooks/useCurrentUser.ts b/lib/hooks/useCurrentUser.ts index 95f9fef..8bf6b36 100644 --- a/lib/hooks/useCurrentUser.ts +++ b/lib/hooks/useCurrentUser.ts @@ -1,21 +1,24 @@ "use client"; - +import { useEffect } from "react"; import currentUserStore from "@/lib/stores/currentUser"; // import useEvents from "@/lib/hooks/useEvents"; import { UserSchema } from "@/types"; import { useNDK } from "@/app/_providers/ndk"; import { nip19 } from "nostr-tools"; import useLists from "./useLists"; +import useSubscriptions from "./useSubscriptions"; + export default function useCurrentUser() { const { currentUser, setCurrentUser, - setFollows, updateCurrentUser, follows, + setFollows, } = currentUserStore(); - const { loginWithNip07, getProfile, ndk } = useNDK(); + const { loginWithNip07, getProfile, ndk, fetchEvents } = useNDK(); const { init } = useLists(); + const { init: initSubscriptions, mySubscription } = useSubscriptions(); async function attemptLogin() { try { const shouldReconnect = localStorage.getItem("shouldReconnect"); @@ -24,8 +27,9 @@ export default function useCurrentUser() { if (!user) { throw new Error("NO auth"); } - console.log("LOGIN", user); - await loginWithPubkey(nip19.decode(user.npub).data.toString()); + const pubkey = nip19.decode(user.npub).data.toString(); + await loginWithPubkey(pubkey); + void initSubscriptions(pubkey); if (typeof window.webln !== "undefined") { await window.webln.enable(); } @@ -66,6 +70,15 @@ export default function useCurrentUser() { void init(user.pubkey); } + useEffect(() => { + if (!currentUser) return; + console.log("fetching follows"); + (async () => { + const following = await currentUser.follows(); + setFollows(following); + })(); + }, [currentUser]); + return { currentUser, isLoading: false, @@ -75,5 +88,7 @@ export default function useCurrentUser() { updateUser: handleUpdateUser, loginWithPubkey, attemptLogin, + initSubscriptions, + mySubscription, }; } diff --git a/lib/hooks/useSubscriptions.ts b/lib/hooks/useSubscriptions.ts new file mode 100644 index 0000000..5d6b24a --- /dev/null +++ b/lib/hooks/useSubscriptions.ts @@ -0,0 +1,35 @@ +"use client"; + +import subscriptionsStore from "@/lib/stores/subscriptions"; +import { useNDK } from "@/app/_providers/ndk"; +import { useState } from "react"; +import { NDKList, NDKKind } from "@nostr-dev-kit/ndk"; + +export default function useSubscriptions() { + const [isLoading, setIsLoading] = useState(false); + const { mySubscription, setMySubscription } = subscriptionsStore(); + const { fetchEvents, ndk } = useNDK(); + async function init(pubkey: string) { + setIsLoading(true); + try { + const subscriptionLists = await fetchEvents({ + kinds: [30044 as NDKKind], + authors: [pubkey], + }); + if (subscriptionLists[0]) { + setMySubscription(new NDKList(ndk, subscriptionLists[0].rawEvent())); + } + } catch (err) { + console.log("error in init", err); + } finally { + setIsLoading(false); + } + } + + return { + setMySubscription, + isLoading, + init, + mySubscription, + }; +} diff --git a/lib/stores/currentUser.ts b/lib/stores/currentUser.ts index aeb0724..175e76e 100644 --- a/lib/stores/currentUser.ts +++ b/lib/stores/currentUser.ts @@ -5,16 +5,16 @@ type Settings = {}; interface CurrentUserState { currentUser: NDKUser | null; - follows: string[]; + follows: Set; settings: Settings; setCurrentUser: (user: NDKUser | null) => void; updateCurrentUser: (user: Partial) => void; - setFollows: (follows: string[]) => void; + setFollows: (follows: Set) => void; } const currentUserStore = create()((set) => ({ currentUser: null, - follows: [], + follows: new Set(), settings: {}, setCurrentUser: (user) => set((state) => ({ ...state, currentUser: user })), updateCurrentUser: (user) => diff --git a/lib/stores/subscriptions.ts b/lib/stores/subscriptions.ts new file mode 100644 index 0000000..9b9bc1c --- /dev/null +++ b/lib/stores/subscriptions.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; +import { type NDKList } from "@nostr-dev-kit/ndk"; + +interface SubscriptionsState { + mySubscription: NDKList | undefined; + setMySubscription: (sub: NDKList) => void; +} + +const subscriptionsStore = create()((set) => ({ + mySubscription: undefined, + setMySubscription: (sub) => + set((state) => ({ ...state, mySubscription: sub })), +})); + +export default subscriptionsStore;