diff --git a/app/(app)/calendar/[naddr]/_components/CreateEventButton.tsx b/app/(app)/calendar/[naddr]/_components/CreateEventButton.tsx new file mode 100644 index 0000000..c6a6c1b --- /dev/null +++ b/app/(app)/calendar/[naddr]/_components/CreateEventButton.tsx @@ -0,0 +1,19 @@ +import { Button } from "@/components/ui/button"; +import { useModal } from "@/app/_providers/modal/provider"; +import CreateCalendarEventModal from "@/components/Modals/CreateCalendarEvent"; + +type CreateEventButtonProps = { + eventReference: string; +}; + +export default function CreateEventButton({ + eventReference, +}: CreateEventButtonProps) { + const modal = useModal(); + + return ( + + ); +} diff --git a/app/(app)/calendar/[naddr]/_components/EditCalendarButton.tsx b/app/(app)/calendar/[naddr]/_components/EditCalendarButton.tsx new file mode 100644 index 0000000..50025aa --- /dev/null +++ b/app/(app)/calendar/[naddr]/_components/EditCalendarButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@/components/ui/button"; +import { useModal } from "@/app/_providers/modal/provider"; +import RSVPModal from "@/components/Modals/RSVP"; + +type RSVPButtonProps = { + eventReference: string; +}; + +export default function RSVPButton({ eventReference }: RSVPButtonProps) { + const modal = useModal(); + + return ( + + ); +} diff --git a/app/(app)/calendar/[naddr]/_components/Header.tsx b/app/(app)/calendar/[naddr]/_components/Header.tsx new file mode 100644 index 0000000..217eb99 --- /dev/null +++ b/app/(app)/calendar/[naddr]/_components/Header.tsx @@ -0,0 +1,158 @@ +"use client"; +import { useEffect, useState } from "react"; +import Image from "next/image"; +import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; +import useProfile from "@/lib/hooks/useProfile"; +import Spinner from "@/components/spinner"; +import { + getTagAllValues, + getTagValues, + getTagsValues, +} from "@/lib/nostr/utils"; +import ProfileInfo from "./ProfileInfo"; +import useCurrentUser from "@/lib/hooks/useCurrentUser"; +import { useNDK } from "@/app/_providers/ndk"; +import { toast } from "sonner"; +import { + sendZap, + checkPayment, + updateListUsersFromZaps, +} from "@/lib/actions/zap"; +import { useModal } from "@/app/_providers/modal/provider"; +import { type NDKEvent } from "@nostr-dev-kit/ndk"; +import { btcToSats, formatNumber } from "@/lib/utils"; + +const CreateEventButton = dynamic(() => import("./CreateEventButton"), { + ssr: false, +}); +const EditCalendarButton = dynamic(() => import("./EditCalendarButton"), { + ssr: false, +}); + +export default function Header({ event }: { event: NDKEvent }) { + const { currentUser } = useCurrentUser(); + const modal = useModal(); + const { ndk } = useNDK(); + const [checkingPayment, setCheckingPayment] = useState(false); + const [hasValidPayment, setHasValidPayment] = useState(false); + const { pubkey, tags } = event; + const { profile } = useProfile(pubkey); + const eventReference = event.encode(); + const name = getTagValues("name", tags) ?? "Untitled"; + const image = + getTagValues("image", tags) ?? + getTagValues("picture", tags) ?? + getTagValues("banner", tags) ?? + profile?.banner; + + const description = event.content; + + const rawEvent = event.rawEvent(); + const priceInBTC = parseFloat(getTagValues("price", rawEvent.tags) ?? "0"); + const isMember = + currentUser && + getTagsValues("p", rawEvent.tags).includes(currentUser.pubkey); + + useEffect(() => { + if (!currentUser || !false) return; + if (!isMember && !checkingPayment && !hasValidPayment) { + void handleCheckPayment(); + } + }, [isMember, currentUser]); + + 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); + } + } + + async function handleSendZap() { + try { + const result = await sendZap( + ndk!, + btcToSats(priceInBTC), + rawEvent, + `Access payment: ${name}`, + ); + toast.success("Payment Sent!"); + void handleCheckPayment(); + } catch (err) { + console.log("error sending zap", err); + } finally { + } + } + if (!event) { + return ( +
+ +
+ ); + } + return ( +
+
+
+ {!!image && ( + banner + )} +
+
+
+
+
+

+ {name} +

+
+ +
+
+
+ {!!currentUser && currentUser.pubkey === pubkey && ( + <> + + + + )} +
+
+
+
+ {!!description && ( +

+ {description} +

+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/app/(app)/calendar/[naddr]/_components/ProfileInfo.tsx b/app/(app)/calendar/[naddr]/_components/ProfileInfo.tsx new file mode 100644 index 0000000..8595e7d --- /dev/null +++ b/app/(app)/calendar/[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)/calendar/[naddr]/page.tsx b/app/(app)/calendar/[naddr]/page.tsx new file mode 100644 index 0000000..aa276f4 --- /dev/null +++ b/app/(app)/calendar/[naddr]/page.tsx @@ -0,0 +1,70 @@ +"use client"; +import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { nip19 } from "nostr-tools"; +import useEvents from "@/lib/hooks/useEvents"; +import Spinner from "@/components/spinner"; +import { + getTagAllValues, + getTagValues, + getTagsAllValues, + getTagsValues, +} from "@/lib/nostr/utils"; +import { type NDKKind } from "@nostr-dev-kit/ndk"; +import Header from "./_components/Header"; +import EventsFromCalendar from "@/containers/EventsTimeline/EventsFromCalendar"; + +export default function EventPage({ + 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 { tags } = event; + const eventReference = event.encode(); + + return ( +
+
+
+
+

Upcoming Events

+
+
+ ( +
+

No upcoming events

+
+ )} + /> +
+
+
+ ); +} diff --git a/app/(app)/event/[naddr]/_components/DiscussionContainer.tsx b/app/(app)/event/[naddr]/_components/DiscussionContainer.tsx index b53eb3c..3190ae0 100644 --- a/app/(app)/event/[naddr]/_components/DiscussionContainer.tsx +++ b/app/(app)/event/[naddr]/_components/DiscussionContainer.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import Feed from "@/containers/Feed"; import CreateKind1Modal from "@/components/Modals/Kind1"; import { useModal } from "@/app/_providers/modal/provider"; +import { getTagValues } from "@/lib/nostr/utils"; type DiscussionContainerProps = { eventReference: string; @@ -32,6 +33,7 @@ export default function DiscussionContainer({
getTagValues("l", e.tags) !== "announcement"} filter={{ kinds: [1], ["#a"]: [eventReference], diff --git a/app/(app)/events/page.tsx b/app/(app)/events/page.tsx index 2f5e58a..ecff194 100644 --- a/app/(app)/events/page.tsx +++ b/app/(app)/events/page.tsx @@ -1,30 +1,6 @@ -"use client"; -import { NDKEvent, type NDKKind } from "@nostr-dev-kit/ndk"; -import CalendarSection, { - CalendarSectionLoading, -} from "./_components/CalendarSection"; -import useEvents from "@/lib/hooks/useEvents"; -import { getTagValues } from "@/lib/nostr/utils"; -import { fromUnix, daysOffset } from "@/lib/utils/dates"; -export default function Page() { - const { events, isLoading } = useEvents({ - filter: { - kinds: [31923 as NDKKind], - limit: 100, - }, - }); - const eventsByDay = groupEventsByDay(events); - - if (isLoading && !eventsByDay.length) { - return ( -
-
- -
-
- ); - } - +import { type NDKKind } from "@nostr-dev-kit/ndk"; +import EventsTimeline from "@/containers/EventsTimeline"; +export default function EventsPage() { return (
@@ -33,40 +9,8 @@ export default function Page() {
- {eventsByDay.map((e) => ( - - ))} +
); } - -function groupEventsByDay(events: NDKEvent[]) { - const eventDays: Record = {}; - for (const event of events) { - const eventStartTime = getTagValues("start", event.tags); - if (!eventStartTime) continue; - const startDate = fromUnix(parseInt(eventStartTime)); - const daysAway = daysOffset(startDate); - if (daysAway < 1) continue; - if (eventDays[`${daysAway}`]) { - eventDays[`${daysAway}`]!.push(event); - } else { - eventDays[`${daysAway}`] = [event]; - } - } - const groupedArray = Object.entries(eventDays) - .sort(([aKey], [bKey]) => { - const aDay = parseInt(aKey); - - const bDay = parseInt(bKey); - if (aDay > bDay) { - return 1; - } else if (aDay < bDay) { - return -1; - } - return 0; - }) - .map(([_, events]) => events); - return groupedArray; -} diff --git a/app/(app)/explore/_components/CalendarCard.tsx b/app/(app)/explore/_components/CalendarCard.tsx new file mode 100644 index 0000000..4e8336c --- /dev/null +++ b/app/(app)/explore/_components/CalendarCard.tsx @@ -0,0 +1,146 @@ +"use client"; +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { RiArrowRightLine } from "react-icons/ri"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { BANNER } from "@/constants/app"; +import { getNameToShow } from "@/lib/utils"; +import { nip19 } from "nostr-tools"; +import useProfile from "@/lib/hooks/useProfile"; +import useEvents from "@/lib/hooks/useEvents"; +import { getTagAllValues, getTagValues } from "@/lib/nostr/utils"; +import { useNDK } from "@/app/_providers/ndk"; + +type CalendarCardProps = { + calendar: NDKEvent; +}; + +export default function CalendarCard({ calendar }: CalendarCardProps) { + const { pubkey, tags, content } = calendar; + const { ndk } = useNDK(); + const npub = nip19.npubEncode(pubkey); + const { profile } = useProfile(pubkey); + const encodedEvent = calendar.encode(); + const [upcomingEvents, setUpcomingEvents] = useState([]); + const [isFetching, setIsFetching] = useState(false); + const name = getTagValues("name", tags); + const description = content ?? getTagValues("about", tags); + const calendarEvents = getTagAllValues("a", tags); + const calendarEventIdentifiers = calendarEvents + .map((e) => nip19.decode(e)) + .filter(({ type }) => type === "naddr") + .map((e) => e.data as nip19.AddressPointer); + + async function handleFetchEvents(data: nip19.AddressPointer[]) { + if (!ndk) return; + setIsFetching(true); + const events: NDKEvent[] = []; + const promiseArray = []; + for (const info of data) { + const calendarEventPromise = ndk + .fetchEvent({ + authors: [info.pubkey], + ["#d"]: [info.identifier], + kinds: [info.kind], + }) + .then((e) => e && events.push(e)) + .catch((err) => console.log("err")); + promiseArray.push(calendarEventPromise); + } + await Promise.all(promiseArray); + setUpcomingEvents(events); + setIsFetching(false); + } + + useEffect(() => { + if ( + !ndk || + calendarEventIdentifiers.length === 0 || + isFetching || + upcomingEvents.length + ) + return; + handleFetchEvents(calendarEventIdentifiers); + }, [ndk, calendarEventIdentifiers]); + + return ( + + background +
+
+ + + + {name} + + + + {description} + + + user + + + Upcoming Events: + + +
    + {upcomingEvents.map((item) => { + const { tags, content } = item; + const encodedEvent = item.encode(); + const name = getTagValues("name", tags); + const description = content; + return ( +
  • + +
    +

    + {name} +

    +

    + {description ?? ""} +

    +
    +
    + +
    + +
  • + ); + })} +
+
+
+
+
+
+ ); +} diff --git a/app/(app)/explore/_sections/CreateEvents.tsx b/app/(app)/explore/_sections/CreateEvents.tsx index 3ef29b9..d579c9a 100644 --- a/app/(app)/explore/_sections/CreateEvents.tsx +++ b/app/(app)/explore/_sections/CreateEvents.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { Button } from "@/components/ui/button"; import { useModal } from "@/app/_providers/modal/provider"; -import CreateSubscriptionTier from "@/components/Modals/CreateSubscriptionTier"; +import CreateCalendarEvent from "@/components/Modals/CreateCalendarEvent"; export default function BecomeACreator() { const modal = useModal(); @@ -26,8 +26,8 @@ export default function BecomeACreator() { Start organizing your events an calendar on directly on Nostr. Seamlessly collect payments and engage with your community.
- diff --git a/app/(app)/explore/_sections/ExploreCalendars.tsx b/app/(app)/explore/_sections/ExploreCalendars.tsx new file mode 100644 index 0000000..a502d39 --- /dev/null +++ b/app/(app)/explore/_sections/ExploreCalendars.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { RiArrowRightLine } from "react-icons/ri"; +import useEvents from "@/lib/hooks/useEvents"; +import useProfile from "@/lib/hooks/useProfile"; +import { nip19 } from "nostr-tools"; +import { EXPLORE_CALENDARS } from "@/constants/app"; +import { getTagValues } from "@/lib/nostr/utils"; +import { NDKEvent, NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk"; +import { useNDK } from "@nostr-dev-kit/ndk-react"; + +import CalendarCard from "../_components/CalendarCard"; +export default function ExploreCalendars() { + return ( +
+
+

+ Explore Calendars +

+ +
+ +
+ ); +} + +function HorizontalCarousel() { + const { events } = useEvents({ + filter: { + kinds: [31924 as NDKKind], + limit: 5, + }, + }); + return ( +
+ {events.map((calendar, index) => ( +
+ +
+ ))} +
+ ); +} + +function Calendar({ encodedEvent }: { encodedEvent: string }) { + const { type, data } = nip19.decode(encodedEvent); + if (type !== "naddr") { + throw new Error("impoper type"); + } + const { pubkey, identifier, kind } = data; + const { events } = useEvents({ + filter: { + authors: [pubkey], + ["#d"]: [identifier], + kinds: [31924 as NDKKind], + limit: 1, + }, + }); + const event = events[0]; + if (!event) { + return null; + } + + return ; +} diff --git a/app/(app)/explore/page.tsx b/app/(app)/explore/page.tsx index 4f58e8f..92fd5fe 100644 --- a/app/(app)/explore/page.tsx +++ b/app/(app)/explore/page.tsx @@ -1,5 +1,6 @@ import dynamic from "next/dynamic"; import ExploreCreators from "./_sections/ExploreCreators"; +import ExploreCalendars from "./_sections/ExploreCalendars"; import UpcomingEvents from "./_sections/UpcomingEvents"; import LongFormContentSection from "./_sections/LongFormContent"; import CreateEvents from "./_sections/CreateEvents"; @@ -23,9 +24,9 @@ const NewEventButton = dynamic(() => import("./_components/NewEventButton"), { export default function Page() { return (
- + - + {/* */} diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx index 0a2ee9a..199c68d 100644 --- a/app/(landing)/page.tsx +++ b/app/(landing)/page.tsx @@ -56,7 +56,7 @@ export default function LandingPage() {

- Own your following. Only on Nostr. + Own your Events. Only on Nostr.

We're bringing the creator economy onto Nostr. The days of diff --git a/components/Cards/CalendarEvent/LargeFeedCard.tsx b/components/Cards/CalendarEvent/LargeFeedCard.tsx index 53f145f..c054ad6 100644 --- a/components/Cards/CalendarEvent/LargeFeedCard.tsx +++ b/components/Cards/CalendarEvent/LargeFeedCard.tsx @@ -1,5 +1,5 @@ import { formatDate, fromUnix } from "@/lib/utils/dates"; -import { BANNER } from "@/constants"; +import Link from "next/link"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import Image from "next/image"; @@ -13,7 +13,11 @@ import { import SmallProfileLine from "@/components/ProfileContainers/SmallProfileLine"; import AvatarStack from "@/components/ProfileContainers/AvatarStack"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -import { getTagValues, getTagAllValues } from "@/lib/nostr/utils"; +import { + getTagValues, + getTagAllValues, + getTagsValues, +} from "@/lib/nostr/utils"; import { HiOutlineMapPin, HiOutlineUserCircle } from "react-icons/hi2"; import { RxClock, RxCalendar } from "react-icons/rx"; import { Skeleton } from "@/components/ui/skeleton"; @@ -28,7 +32,8 @@ export default function LargeFeedCard({ event }: LargeFeedCardProps) { const image = getTagValues("image", tags); const location = getTagValues("location", tags) ?? getTagValues("address", tags); - const users = getTagAllValues("p", tags); + const users = getTagsValues("p", tags).filter(Boolean); + console.log("Users", users); const startDate = getTagValues("start", tags) ? new Date(parseInt(getTagValues("start", tags) as string) * 1000) : null; @@ -58,7 +63,7 @@ export default function LargeFeedCard({ event }: LargeFeedCardProps) { 2 ? users.length - 4 : 0} />

@@ -89,9 +94,11 @@ export default function LargeFeedCard({ event }: LargeFeedCardProps) {
- + + +
diff --git a/components/FormComponents/Picker.tsx b/components/FormComponents/Picker.tsx new file mode 100644 index 0000000..ad16ac9 --- /dev/null +++ b/components/FormComponents/Picker.tsx @@ -0,0 +1,88 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { HiChevronDown, HiCheck } from "react-icons/hi2"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +type PickerProps = { + options: T[]; + value: string | undefined; + noun: string; + placeholder: string; + onChange: (val: T) => void; + className?: string; + pre?: React.ReactNode; +}; +export default function Picker({ + value, + onChange, + options, + noun, + placeholder, + className, + pre, +}: PickerProps) { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + + + {`No ${noun} Found.`} + + {options.map((option) => ( + { + onChange(option); + setOpen(false); + }} + > + {option.label} + + + ))} + + + + + ); +} diff --git a/components/Modals/CreateCalendarEvent.tsx b/components/Modals/CreateCalendarEvent.tsx index 745b607..747420b 100644 --- a/components/Modals/CreateCalendarEvent.tsx +++ b/components/Modals/CreateCalendarEvent.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from "react"; import Image from "next/image"; import { HiX } from "react-icons/hi"; +import { HiOutlineCalendarDays } from "react-icons/hi2"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { randomId } from "@/lib/nostr"; @@ -15,6 +16,7 @@ import { DatePicker } from "@/components/ui/date-picker"; import { TimePicker } from "@/components/ui/time-picker"; import { TimezoneSelector } from "@/components/ui/timezone"; import { Label } from "@/components/ui/label"; +import Picker from "@/components/FormComponents/Picker"; import SmallCalendarIcon from "@/components/EventIcons/DateIcon"; import LocationIcon from "@/components/EventIcons/LocationIcon"; @@ -24,10 +26,12 @@ import Spinner from "../spinner"; import useAutosizeTextArea from "@/lib/hooks/useAutoSizeTextArea"; import { useModal } from "@/app/_providers/modal/provider"; import { useRouter } from "next/navigation"; -import { createEvent } from "@/lib/actions/create"; +import { createEvent, updateList } from "@/lib/actions/create"; import { useNDK } from "@/app/_providers/ndk"; import useCurrentUser from "@/lib/hooks/useCurrentUser"; import useImageUpload from "@/lib/hooks/useImageUpload"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { getTagValues } from "@/lib/nostr/utils"; export default function CreateCalendarEventModal() { const modal = useModal(); @@ -66,6 +70,7 @@ export default function CreateCalendarEventModal() { const [timezone, setTimezone] = useState( Intl.DateTimeFormat().resolvedOptions().timeZone, ); + const [calendar, setCalendar] = useState(); const [location, setLocation] = useState<{ address: string; name: string; @@ -73,11 +78,10 @@ export default function CreateCalendarEventModal() { geohash: string; }>(); const { ndk } = useNDK(); - const { currentUser } = useCurrentUser(); + const { currentUser, calendars } = useCurrentUser(); const router = useRouter(); async function handleSubmit() { - console.log("CALLED", ndk, currentUser); if (!ndk || !currentUser) { alert("MISSING"); return; @@ -130,9 +134,21 @@ export default function CreateCalendarEventModal() { }; const event = await createEvent(ndk, preEvent); if (event) { + const encodedEvent = event.encode(); + if (calendar) { + console.log("calendar", calendar); + const selectedCalendar = Array.from(calendars) + .find((option) => option.encode() === calendar) + ?.rawEvent(); + if (selectedCalendar) { + console.log("selectedCalendar", selectedCalendar); + + await updateList(ndk, selectedCalendar, [["a", encodedEvent]]); + } + } toast.success("Event Created!"); modal?.hide(); - router.push(`/event/${event.encode()}`); + router.push(`/event/${encodedEvent}`); } else { toast.error("An error occured"); } @@ -279,6 +295,79 @@ export default function CreateCalendarEventModal() {
+
+
+
+ + ({ + ...o, + label: getTagValues("name", o.tags) as string, + value: o.encode(), + }) as NDKEvent & { + label: string; + value: string; + }, + )} + noun="Calendar" + placeholder="Add to a Calendar" + pre={ + + } + className="px-0 pr-1 font-normal" + value={calendar} + onChange={(calendar) => setCalendar(calendar.encode())} + /> +
+
+
+ + {/* Image Upload */} +
+ {imagePreview ? ( +
+
+ Image +
+ {imageStatus === "uploading" && ( + + )} + {imageStatus === "success" && ( + + )} +
+ ) : ( + + + + )}
@@ -307,7 +396,7 @@ export default function CreateCalendarEventModal() { placeholder="Some into about this event..." />
-
+
{imagePreview ? (
diff --git a/components/ProfileContainers/AvatarStack.tsx b/components/ProfileContainers/AvatarStack.tsx index 762e6fe..8cc254d 100644 --- a/components/ProfileContainers/AvatarStack.tsx +++ b/components/ProfileContainers/AvatarStack.tsx @@ -19,9 +19,13 @@ export default function AvatarStack({ }: AvatarStackProps) { return (
- {pubkeys.map((p, idx) => ( - - ))} + {pubkeys.map((p, idx) => { + if (p) { + return ( + + ); + } + })} {!!remaining && ( + {/* Date Indicator */} +
+ +
+ {/* Date Indicator Mobile */} +
+
+ +
+
+ + {/* Events */} +
+ {events.map((e) => ( + + ))} +
+
+ ); +} +export function CalendarSectionLoading() { + const startDate = addMinutesToDate(new Date(), 60); + + return ( +
+ {/* Date Indicator */} +
+ +
+ {/* Date Indicator Mobile */} +
+
+ +
+
+ + {/* Events */} +
+ + + + +
+
+ ); +} +function CalendarIcon({ date }: { date: Date }) { + return ( +
+
+ {/* 24 */} + {formatDate(date, "ddd")} +
+
+
+ {formatDate(date, "MMMM Do")} + + {relativeTime(date)} + +
+
+
+ ); +} +function CalendarIconOpacity({ date }: { date: Date }) { + const ref = useRef(null); + const [top, setTop] = useState(false); + + useEffect(() => { + // Add a scroll event listener to the window + const handleScroll = () => { + if (ref.current) { + // Get the position of the div relative to the viewport + const divRect = ref.current.getBoundingClientRect(); + // Change the opacity when the div reaches the top of the screen + if (divRect.top <= 145) { + setTop(true); + } else { + setTop(false); + } + } + }; + + window.addEventListener("scroll", handleScroll); + + return () => { + // Remove the scroll event listener when the component unmounts + window.removeEventListener("scroll", handleScroll); + }; + }, []); + return ( +
+ +
+
+ ); +} diff --git a/containers/EventsTimeline/EventsFromCalendar.tsx b/containers/EventsTimeline/EventsFromCalendar.tsx new file mode 100644 index 0000000..732c08b --- /dev/null +++ b/containers/EventsTimeline/EventsFromCalendar.tsx @@ -0,0 +1,80 @@ +"use client"; +import { useState, useEffect } from "react"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { getTagAllValues } from "@/lib/nostr/utils"; +import { groupEventsByDay } from "."; +import { useNDK } from "@/app/_providers/ndk"; +import { nip19 } from "nostr-tools"; +import CalendarSection, { CalendarSectionLoading } from "./CalendarSection"; + +type EventsFromCalendar = { + calendar: NDKEvent; + loader?: () => JSX.Element; + empty?: () => JSX.Element; +}; + +export default function EventsFromCalendar({ + calendar, + loader: Loader, + empty: Empty, +}: EventsFromCalendar) { + const calendarEvents = getTagAllValues("a", calendar.tags); + const { ndk } = useNDK(); + const [events, setEvents] = useState([]); + const [isFetching, setIsFetching] = useState(false); + const calendarEventIdentifiers = calendarEvents + .map((e) => nip19.decode(e)) + .filter(({ type }) => type === "naddr") + .map((e) => e.data as nip19.AddressPointer); + + async function handleFetchEvents(data: nip19.AddressPointer[]) { + if (!ndk) return; + setIsFetching(true); + const events: NDKEvent[] = []; + const promiseArray = []; + for (const info of data) { + const calendarEventPromise = ndk + .fetchEvent({ + authors: [info.pubkey], + ["#d"]: [info.identifier], + kinds: [info.kind], + }) + .then((e) => e && events.push(e)) + .catch((err) => console.log("err")); + promiseArray.push(calendarEventPromise); + } + await Promise.all(promiseArray); + setEvents(events); + setIsFetching(false); + } + + useEffect(() => { + if ( + !ndk || + calendarEventIdentifiers.length === 0 || + isFetching || + events.length + ) + return; + handleFetchEvents(calendarEventIdentifiers); + }, [ndk, calendarEventIdentifiers]); + + const eventsByDay = groupEventsByDay(events); + + if (isFetching) { + if (Loader) { + return ; + } + return ; + } + if (Empty && eventsByDay.length === 0) { + return ; + } + return ( + <> + {eventsByDay.map((e) => ( + + ))} + + ); +} diff --git a/containers/EventsTimeline/index.tsx b/containers/EventsTimeline/index.tsx new file mode 100644 index 0000000..25e1d01 --- /dev/null +++ b/containers/EventsTimeline/index.tsx @@ -0,0 +1,73 @@ +"use client"; +import useEvents from "@/lib/hooks/useEvents"; +import { + type NDKEvent, + type NDKKind, + type NDKFilter, +} from "@nostr-dev-kit/ndk"; +import { getTagValues } from "@/lib/nostr/utils"; +import { fromUnix, daysOffset } from "@/lib/utils/dates"; +import Spinner from "@/components/spinner"; +import CalendarSection, { CalendarSectionLoading } from "./CalendarSection"; + +type EventsTimelineProps = { + filter?: NDKFilter; + loader?: () => JSX.Element; + empty?: () => JSX.Element; +}; +export default function EventsTimeline({ + filter, + loader: Loader, + empty: Empty, +}: EventsTimelineProps) { + const { events, isLoading } = useEvents({ + filter: { kinds: [31923 as NDKKind], ...filter }, + }); + const eventsByDay = groupEventsByDay(events); + + if (isLoading) { + if (Loader) { + return ; + } + return ; + } + if (Empty && eventsByDay.length === 0) { + return ; + } + return ( + <> + {eventsByDay.map((e) => ( + + ))} + + ); +} +export function groupEventsByDay(events: NDKEvent[]) { + const eventDays: Record = {}; + for (const event of events) { + const eventStartTime = getTagValues("start", event.tags); + if (!eventStartTime) continue; + const startDate = fromUnix(parseInt(eventStartTime)); + const daysAway = daysOffset(startDate); + if (daysAway < 1) continue; + if (eventDays[`${daysAway}`]) { + eventDays[`${daysAway}`]!.push(event); + } else { + eventDays[`${daysAway}`] = [event]; + } + } + const groupedArray = Object.entries(eventDays) + .sort(([aKey], [bKey]) => { + const aDay = parseInt(aKey); + + const bDay = parseInt(bKey); + if (aDay > bDay) { + return 1; + } else if (aDay < bDay) { + return -1; + } + return 0; + }) + .map(([_, events]) => events); + return groupedArray; +} diff --git a/containers/Feed/index.tsx b/containers/Feed/index.tsx index cdb8666..1173731 100644 --- a/containers/Feed/index.tsx +++ b/containers/Feed/index.tsx @@ -4,9 +4,10 @@ import { cn } from "@/lib/utils"; import Spinner from "@/components/spinner"; import { Event } from "nostr-tools"; import useEvents from "@/lib/hooks/useEvents"; -import { type NDKFilter } from "@nostr-dev-kit/ndk"; +import { NDKEvent, type NDKFilter } from "@nostr-dev-kit/ndk"; type FeedProps = { filter?: NDKFilter; + secondaryFilter?: (event: NDKEvent) => Boolean; className?: string; loader?: () => JSX.Element; empty?: () => JSX.Element; @@ -14,7 +15,7 @@ type FeedProps = { export default function Feed({ filter, - className, + secondaryFilter, loader: Loader, empty: Empty, }: FeedProps) { @@ -30,6 +31,16 @@ export default function Feed({ if (Empty && events.length === 0) { return ; } + if (secondaryFilter) { + return ( + <> + {events.filter(secondaryFilter).map((e) => { + const event = e.rawEvent() as Event; + return ; + })} + + ); + } return ( <> {events.map((e) => { diff --git a/lib/hooks/useCurrentUser.ts b/lib/hooks/useCurrentUser.ts index f6f43a5..c2dbdaa 100644 --- a/lib/hooks/useCurrentUser.ts +++ b/lib/hooks/useCurrentUser.ts @@ -7,7 +7,7 @@ import { useNDK } from "@/app/_providers/ndk"; import { nip19 } from "nostr-tools"; import useLists from "./useLists"; import useSubscriptions from "./useSubscriptions"; -import { db } from "@nostr-dev-kit/ndk-cache-dexie"; +import { type NDKKind } from "@nostr-dev-kit/ndk"; import { webln } from "@getalby/sdk"; const loadNWCUrl = ""; const nwc = new webln.NWC({ nostrWalletConnectUrl: loadNWCUrl }); @@ -20,6 +20,8 @@ export default function useCurrentUser() { follows, setFollows, addFollow, + calendars, + setCalendars, } = currentUserStore(); const { loginWithNip07, loginWithNip46, getProfile, ndk, fetchEvents } = useNDK(); @@ -86,14 +88,24 @@ export default function useCurrentUser() { useEffect(() => { if (!currentUser) return; - console.log("fetching follows"); + console.log("fetching follows & calendar"); (async () => { const following = await currentUser.follows(); console.log("Follows", following); setFollows(following); + await fetchCalendars(); })(); }, [currentUser]); + async function fetchCalendars() { + if (!ndk || !currentUser) return; + const calendars = await ndk.fetchEvents({ + authors: [currentUser.pubkey], + kinds: [31924 as NDKKind], + }); + setCalendars(new Set(calendars)); + } + return { currentUser, isLoading: false, @@ -107,5 +119,7 @@ export default function useCurrentUser() { mySubscription, addFollow, setFollows, + calendars, + fetchCalendars, }; } diff --git a/lib/hooks/useEvents.ts b/lib/hooks/useEvents.ts index cdffb97..18e4276 100644 --- a/lib/hooks/useEvents.ts +++ b/lib/hooks/useEvents.ts @@ -95,3 +95,73 @@ export default function useEvents({ }, }; } +export function useEvent({ + filter, + enabled = true, + eventFilter = () => true, +}: UseEventsProps) { + const [isLoading, setIsLoading] = useState(true); + const [sub, setSub] = useState(undefined); + const [event, setEvent] = useState(); + const [eventId, setEventId] = useState(); + let onEventCallback: null | OnEventFunc = null; + let onSubscribeCallback: null | OnSubscribeFunc = null; + let onDoneCallback: null | OnDoneFunc = null; + const { ndk } = useNDK(); + + useEffect(() => { + if (!enabled || !ndk) return; + void init(); + return () => { + console.log("STOPPING", sub); + if (sub) { + sub.stop(); + } + }; + }, [enabled, ndk]); + + async function init() { + setIsLoading(true); + try { + const sub = ndk!.subscribe( + { limit: 1, ...filter }, + { closeOnEose: false }, + ); + setSub(sub); + onSubscribeCallback?.(sub); + sub.on("event", (e, r) => { + if (eventId === e.id) { + return; + } + if (eventFilter(e)) { + setEvent(e); + setEventId(e.id); + } + }); + } catch (err) { + log("error", `❌ nostr (${err})`); + } finally { + setIsLoading(false); + } + } + + return { + isLoading, + event, + onEvent: (_onEventCallback: OnEventFunc) => { + if (_onEventCallback) { + onEventCallback = _onEventCallback; + } + }, + onDone: (_onDoneCallback: OnDoneFunc) => { + if (_onDoneCallback) { + onDoneCallback = _onDoneCallback; + } + }, + onSubscribe: (_onSubscribeCallback: OnSubscribeFunc) => { + if (_onSubscribeCallback) { + onSubscribeCallback = _onSubscribeCallback; + } + }, + }; +} diff --git a/lib/stores/currentUser.ts b/lib/stores/currentUser.ts index c8a40b0..e8bc328 100644 --- a/lib/stores/currentUser.ts +++ b/lib/stores/currentUser.ts @@ -1,11 +1,13 @@ import { create } from "zustand"; -import { type NDKUser } from "@nostr-dev-kit/ndk"; +import { NDKEvent, type NDKUser } from "@nostr-dev-kit/ndk"; type Settings = {}; interface CurrentUserState { currentUser: NDKUser | null; follows: Set; + calendars: Set; + setCalendars: (calendars: Set) => void; settings: Settings; setCurrentUser: (user: NDKUser | null) => void; updateCurrentUser: (user: Partial) => void; @@ -16,6 +18,7 @@ interface CurrentUserState { const currentUserStore = create()((set) => ({ currentUser: null, follows: new Set(), + calendars: new Set(), settings: {}, setCurrentUser: (user) => set((state) => ({ ...state, currentUser: user })), updateCurrentUser: (user) => @@ -24,6 +27,8 @@ const currentUserStore = create()((set) => ({ currentUser: { ...state.currentUser, ...user } as NDKUser, })), setFollows: (follows) => set((state) => ({ ...state, follows: follows })), + setCalendars: (calendars) => + set((state) => ({ ...state, calendars: calendars })), addFollow: (follow) => set((state) => ({ ...state, follows: new Set(state.follows).add(follow) })), }));