From c940b42ed3d46427b78dcd84f771c178f66c030f Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Tue, 7 Jan 2025 16:53:51 -0600 Subject: [PATCH] add prototype podcast views --- package.json | 1 + pnpm-lock.yaml | 9 ++ src/app.tsx | 17 +++ src/helpers/debug.ts | 4 +- src/helpers/nostr/podcasts.ts | 118 ++++++++++++++++++ src/hooks/use-feed-xml.ts | 8 ++ src/hooks/use-user-podcasts.ts | 15 +++ src/services/xml-feeds.ts | 29 +++++ src/views/other-stuff/apps.ts | 2 + .../podcasts/components/add-feed-form.tsx | 91 ++++++++++++++ .../podcasts/components/episode-card.tsx | 36 ++++++ .../podcasts/components/podcast-feed-card.tsx | 49 ++++++++ src/views/podcasts/index.tsx | 65 ++++++++++ src/views/podcasts/podcast/episode/index.tsx | 46 +++++++ src/views/podcasts/podcast/index.tsx | 106 ++++++++++++++++ src/views/settings/post/index.tsx | 4 +- 16 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 src/helpers/nostr/podcasts.ts create mode 100644 src/hooks/use-feed-xml.ts create mode 100644 src/hooks/use-user-podcasts.ts create mode 100644 src/services/xml-feeds.ts create mode 100644 src/views/podcasts/components/add-feed-form.tsx create mode 100644 src/views/podcasts/components/episode-card.tsx create mode 100644 src/views/podcasts/components/podcast-feed-card.tsx create mode 100644 src/views/podcasts/index.tsx create mode 100644 src/views/podcasts/podcast/episode/index.tsx create mode 100644 src/views/podcasts/podcast/index.tsx diff --git a/package.json b/package.json index 48eb1be24..ccf674b3a 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "three-stdlib": "^2.35.2", "tiny-lru": "^11.2.11", "unified": "^11.0.5", + "uuid": "^11.0.4", "vite-plugin-funding": "^0.1.0", "webln": "^0.3.2", "workbox-core": "7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89e97e7de..1921dbdc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: unified: specifier: ^11.0.5 version: 11.0.5 + uuid: + specifier: ^11.0.4 + version: 11.0.4 vite-plugin-funding: specifier: ^0.1.0 version: 0.1.0 @@ -4327,6 +4330,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + uuid@11.0.4: + resolution: {integrity: sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -9359,6 +9366,8 @@ snapshots: dependencies: react: 18.3.1 + uuid@11.0.4: {} + uuid@9.0.1: {} valid-url@1.0.9: {} diff --git a/src/app.tsx b/src/app.tsx index 6f3721ef3..7b45f5e90 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -144,6 +144,10 @@ const WikiCompareView = lazy(() => import("./views/wiki/compare")); const CreateWikiPageView = lazy(() => import("./views/wiki/create")); const EditWikiPageView = lazy(() => import("./views/wiki/edit")); +const PodcastsHomeView = lazy(() => import("./views/podcasts")); +const PodcastView = lazy(() => import("./views/podcasts/podcast")); +const EpisodeView = lazy(() => import("./views/podcasts/podcast/episode")); + const RootPage = () => { useSetColorMode(); @@ -365,6 +369,19 @@ const router = createHashRouter([ }, ], }, + { + path: "podcasts", + element: ( + + + + ), + children: [ + { path: "", element: }, + { path: ":guid", element: }, + { path: ":guid/:episode", element: }, + ], + }, { path: "videos", children: [ diff --git a/src/helpers/debug.ts b/src/helpers/debug.ts index ea1874cdd..f242bd532 100644 --- a/src/helpers/debug.ts +++ b/src/helpers/debug.ts @@ -1,6 +1,6 @@ import debug from "debug"; -if (!localStorage.getItem("debug") && import.meta.env.DEV) - debug.enable("noStrudel,noStrudel:*,applesauce,applesauce:*"); +// if (!localStorage.getItem("debug") && import.meta.env.DEV) +// debug.enable("noStrudel,noStrudel:*,applesauce,applesauce:*"); export const logger = debug("noStrudel"); diff --git a/src/helpers/nostr/podcasts.ts b/src/helpers/nostr/podcasts.ts new file mode 100644 index 000000000..6450ae83f --- /dev/null +++ b/src/helpers/nostr/podcasts.ts @@ -0,0 +1,118 @@ +import { getHiddenTags, processTags } from "applesauce-core/helpers"; +import { NostrEvent } from "nostr-tools"; +import { v5 as UUIDv5 } from "uuid"; + +export const PODCAST_GUID_NS = "ead4c236-bf58-58c6-a2c6-a6b28d128cb6"; +export const PODCASTS_LIST_KIND = 10104; + +export const NAMESPACES = { + atom: "http://www.w3.org/2005/Atom", + itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd", + podcast: "https://podcastindex.org/namespace/1.0", + spotify: "http://www.spotify.com/ns/rss", + content: "http://purl.org/rss/1.0/modules/content/", +} as const; + +const resolver: XPathNSResolver = (prefix: string | null): string | null => { + return (prefix && NAMESPACES[prefix as keyof typeof NAMESPACES]) || null; +}; + +export type FeedPointer = { + guid: string; + url: URL; +}; + +export function getFeedPointerFromITag(tag: string[]): FeedPointer { + if (tag.length < 3) throw new Error("Tag too short"); + + const guid = tag[1]; + const url = new URL(tag[2]); + + return { guid, url }; +} + +export function getFeedPointers(list: NostrEvent) { + const hidden = getHiddenTags(list); + return processTags(hidden ? [...hidden, ...list.tags] : list.tags, getFeedPointerFromITag); +} + +function getNodeDocument(xml: Document | Node | Element) { + if (xml instanceof Document) return xml; + else if (xml instanceof Element) return xml.ownerDocument; + else if (!xml.parentElement) throw new Error("Disconnected node"); + else return xml.parentElement instanceof Document ? xml.parentElement : xml.parentElement.ownerDocument; +} + +export function getXPathString(xml: Document | Element, selector: string): string; +export function getXPathString(xml: Document | Element, selector: string, safe: true): string | undefined; +export function getXPathString(xml: Document | Element, selector: string, safe: false): string; +export function getXPathString(xml: Document | Element, selector: string, safe = false) { + try { + return getNodeDocument(xml).evaluate(selector, xml, resolver, XPathResult.STRING_TYPE).stringValue; + } catch (error) { + if (!safe) throw error; + } +} +export function getXPathElements(node: Document | Element, selector: string): Element[] { + const iterator = getNodeDocument(node).evaluate(selector, node, resolver, XPathResult.ORDERED_NODE_ITERATOR_TYPE); + const items: Element[] = []; + + let item: Node | null; + while ((item = iterator.iterateNext())) { + if (item instanceof Element) items.push(item); + } + + return items; +} + +export function getPodcastTitle(xml: Document): string { + return getXPathString(xml, "//title"); +} +export function getPodcastDescription(xml: Document): string { + return getXPathString(xml, "//description"); +} +export function getPodcastImageURL(xml: Document): string { + return xml.evaluate("//image/url", xml, resolver, XPathResult.STRING_TYPE).stringValue; +} +export function getPodcastLink(xml: Document): string { + return getXPathString(xml, "//link"); +} +export function getPodcastFeedGUID(url: string) { + return UUIDv5(url.replace(/http?s:\/\//i, ""), PODCAST_GUID_NS); +} +export function getPodcastGUID(xml: Document): string { + let guid = xml.evaluate("//podcast:guid", xml, resolver, XPathResult.STRING_TYPE).stringValue; + if (guid) return guid; + + const link = getXPathString(xml, "//atom:link/@href"); + return getPodcastFeedGUID(link); +} + +export function getPodcastItems(xml: Document) { + return getXPathElements(xml, "//item"); +} + +export type PodcastPerson = { + href?: string; + image?: string; + name: string; + group?: string; + roll?: string; +}; +export function getPodcastPeople(xml: Document | Element) { + const items = getXPathElements(xml, "podcast:person"); + const people: PodcastPerson[] = []; + for (const item of items) { + if (!item.textContent) continue; + + people.push({ + name: item.textContent, + image: item.getAttribute("img") ?? undefined, + href: item.getAttribute("href") ?? undefined, + group: item.getAttribute("group") ?? undefined, + roll: item.getAttribute("roll") ?? undefined, + }); + } + + return people; +} diff --git a/src/hooks/use-feed-xml.ts b/src/hooks/use-feed-xml.ts new file mode 100644 index 000000000..fffb22d89 --- /dev/null +++ b/src/hooks/use-feed-xml.ts @@ -0,0 +1,8 @@ +import { useAsync } from "react-use"; +import { xmlFeedsService } from "../services/xml-feeds"; + +export default function useFeedXML(url: string | URL, force?: boolean) { + const { error, value, loading } = useAsync(() => xmlFeedsService.requestFeed(url, force), [String(url), force]); + + return { xml: value, error, loading }; +} diff --git a/src/hooks/use-user-podcasts.ts b/src/hooks/use-user-podcasts.ts new file mode 100644 index 000000000..de43cae97 --- /dev/null +++ b/src/hooks/use-user-podcasts.ts @@ -0,0 +1,15 @@ +import useReplaceableEvent from "./use-replaceable-event"; +import useCurrentAccount from "./use-current-account"; +import { RequestOptions } from "../services/replaceable-events"; +import { PODCASTS_LIST_KIND } from "../helpers/nostr/podcasts"; + +export default function useUserPodcasts( + pubkey?: string, + additionalRelays?: Iterable, + opts: RequestOptions = {}, +) { + const account = useCurrentAccount(); + pubkey = pubkey || account?.pubkey; + + return useReplaceableEvent(pubkey ? { kind: PODCASTS_LIST_KIND, pubkey } : undefined, additionalRelays, opts); +} diff --git a/src/services/xml-feeds.ts b/src/services/xml-feeds.ts new file mode 100644 index 000000000..848928f19 --- /dev/null +++ b/src/services/xml-feeds.ts @@ -0,0 +1,29 @@ +import { fetchWithProxy } from "../helpers/request"; + +class XmlFeedsService { + parser = new DOMParser(); + feeds = new Map(); + + private async loadFeed(url: string) { + const str = await fetchWithProxy(url).then((res) => res.text()); + + return this.parser.parseFromString(str, "application/xml"); + } + + async requestFeed(url: string | URL, force = false): Promise { + url = String(url); + + if (this.feeds.has(url) && !force) return this.feeds.get(url)!; + + const xml = await this.loadFeed(url); + this.feeds.set(url, xml); + return xml; + } +} + +export const xmlFeedsService = new XmlFeedsService(); + +if (import.meta.env.DEV) { + // @ts-expect-error + window.xmlFeedsService = xmlFeedsService; +} diff --git a/src/views/other-stuff/apps.ts b/src/views/other-stuff/apps.ts index 2c9922207..1fec25169 100644 --- a/src/views/other-stuff/apps.ts +++ b/src/views/other-stuff/apps.ts @@ -23,6 +23,7 @@ import MessageQuestionSquare from "../../components/icons/message-question-squar import UploadCloud01 from "../../components/icons/upload-cloud-01"; import Edit04 from "../../components/icons/edit-04"; import Users03 from "../../components/icons/users-03"; +import Podcast from "../../components/icons/podcast"; export const internalApps: App[] = [ { @@ -39,6 +40,7 @@ export const internalApps: App[] = [ id: "media", to: "/media", }, + // { title: "Podcasts", description: "Social podcasts", icon: Podcast, id: "podcasts", to: "/podcasts" }, { title: "Wiki", description: "Browse wiki pages", icon: WikiIcon, id: "wiki", to: "/wiki" }, { title: "Channels", diff --git a/src/views/podcasts/components/add-feed-form.tsx b/src/views/podcasts/components/add-feed-form.tsx new file mode 100644 index 000000000..7d4db3242 --- /dev/null +++ b/src/views/podcasts/components/add-feed-form.tsx @@ -0,0 +1,91 @@ +import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Switch, useToast } from "@chakra-ui/react"; +import { EventTemplate, NostrEvent } from "nostr-tools"; +import { useForm } from "react-hook-form"; +import useUserPodcasts from "../../../hooks/use-user-podcasts"; +import { ErrorBoundary } from "../../../components/error-boundary"; +import PodcastFeedCard from "./podcast-feed-card"; +import { usePublishEvent } from "../../../providers/global/publish-provider"; +import { getFeedPointers, getPodcastGUID, PODCASTS_LIST_KIND } from "../../../helpers/nostr/podcasts"; +import { xmlFeedsService } from "../../../services/xml-feeds"; +import { unixNow } from "applesauce-core/helpers"; + +export default function AddFeedForm({ onAdded }: { onAdded?: (list: NostrEvent) => void }) { + const toast = useToast(); + const publish = usePublishEvent(); + const { register, formState, handleSubmit, watch, getValues } = useForm({ defaultValues: { url: "", public: true } }); + const list = useUserPodcasts(); + + watch("url"); + + const submit = handleSubmit(async (values) => { + try { + const url = new URL(values.url).toString(); + const xml = await xmlFeedsService.requestFeed(url); + if (!xml) throw new Error("Failed to fetch feed"); + + const guid = getPodcastGUID(xml); + + let draft: EventTemplate; + if (list) { + if (getFeedPointers(list).some((f) => f.guid === guid)) throw new Error("Already subscribed"); + + draft = { + kind: PODCASTS_LIST_KIND, + created_at: unixNow(), + content: list.content, + tags: [...list.tags, ["i", guid, url]], + }; + } else { + draft = { + kind: PODCASTS_LIST_KIND, + tags: [["i", guid, url]], + created_at: unixNow(), + content: "", + }; + } + + const pub = await publish("Add Podcast", draft); + if (pub) onAdded?.(pub?.event); + } catch (error) { + if (error instanceof Error) toast({ status: "error", description: error.message }); + } + }); + + const url = getValues("url"); + + return ( + + + Feed URL + + Enter the feed URL of a podcast + + + + + + Public subscription + + + + + + {url && URL.canParse(url) && ( + + + + )} + + + + + + ); +} diff --git a/src/views/podcasts/components/episode-card.tsx b/src/views/podcasts/components/episode-card.tsx new file mode 100644 index 000000000..b5b4310d9 --- /dev/null +++ b/src/views/podcasts/components/episode-card.tsx @@ -0,0 +1,36 @@ +import { Card, CardBody, CardHeader, Flex, Heading, Image, LinkBox } from "@chakra-ui/react"; +import { Link as RouterLink, useParams, useSearchParams } from "react-router-dom"; + +import { getXPathString } from "../../../helpers/nostr/podcasts"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; + +export default function EpisodeCard({ episode }: { episode: Element }) { + const image = getXPathString(episode, "itunes:image"); + const title = getXPathString(episode, "title"); + const description = getXPathString(episode, "itunes:summary"); + const guid = getXPathString(episode, "guid"); + + const { guid: podcastGuid } = useParams(); + const [search] = useSearchParams(); + + return ( + + {image && } + + + + + {title} + + + + + {description} + + + + ); +} diff --git a/src/views/podcasts/components/podcast-feed-card.tsx b/src/views/podcasts/components/podcast-feed-card.tsx new file mode 100644 index 000000000..a784bd3b7 --- /dev/null +++ b/src/views/podcasts/components/podcast-feed-card.tsx @@ -0,0 +1,49 @@ +import { Card, CardBody, CardHeader, Flex, Heading, Image, LinkBox, Spinner } from "@chakra-ui/react"; +import { To, Link as RouterLink } from "react-router-dom"; + +import { + FeedPointer, + getPodcastDescription, + getPodcastImageURL, + getPodcastTitle, +} from "../../../helpers/nostr/podcasts"; +import useFeedXML from "../../../hooks/use-feed-xml"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; + +export default function PodcastFeedCard({ pointer, to }: { pointer: FeedPointer; to?: To }) { + const { xml } = useFeedXML(pointer.url); + + if (!xml) + return ( + + + + Fetching feed... + + + + {pointer.url.toString()} + + + ); + + const image = getPodcastImageURL(xml); + + return ( + + {image && } + + + + + {getPodcastTitle(xml)} + + + + + {getPodcastDescription(xml)} + + + + ); +} diff --git a/src/views/podcasts/index.tsx b/src/views/podcasts/index.tsx new file mode 100644 index 000000000..879357272 --- /dev/null +++ b/src/views/podcasts/index.tsx @@ -0,0 +1,65 @@ +import { + Button, + Flex, + Heading, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + SimpleGrid, + useDisclosure, +} from "@chakra-ui/react"; +import { useAppTitle } from "../../hooks/use-app-title"; +import { useBreakpointValue } from "../../providers/global/breakpoint-provider"; +import VerticalPageLayout from "../../components/vertical-page-layout"; +import Plus from "../../components/icons/plus"; +import useUserPodcasts from "../../hooks/use-user-podcasts"; +import { getFeedPointers } from "../../helpers/nostr/podcasts"; +import PodcastFeedCard from "./components/podcast-feed-card"; +import AddFeedForm from "./components/add-feed-form"; + +export default function PodcastsHomeView() { + useAppTitle("Podcasts"); + + const add = useDisclosure(); + + const list = useUserPodcasts(); + const feeds = list ? getFeedPointers(list) : []; + + return ( + + + Podcasts + + + + Subscribed + + {feeds.map((feed) => ( + + ))} + + + {add.isOpen && ( + + + + Add feed + + + + + + + )} + + ); +} diff --git a/src/views/podcasts/podcast/episode/index.tsx b/src/views/podcasts/podcast/episode/index.tsx new file mode 100644 index 000000000..52b37a41c --- /dev/null +++ b/src/views/podcasts/podcast/episode/index.tsx @@ -0,0 +1,46 @@ +import { Navigate, useParams, useSearchParams, Link as RouterLink } from "react-router-dom"; +import VerticalPageLayout from "../../../../components/vertical-page-layout"; +import { Button, Flex, Heading, Image, Spinner, Text } from "@chakra-ui/react"; +import ChevronLeft from "../../../../components/icons/chevron-left"; +import useFeedXML from "../../../../hooks/use-feed-xml"; +import { getXPathElements, getXPathString } from "../../../../helpers/nostr/podcasts"; +import BackButton from "../../../../components/router/back-button"; + +function EpisodePage({ episode }: { episode: Element }) { + const image = getXPathString(episode, "itunes:image"); + const title = getXPathString(episode, "title"); + const description = getXPathString(episode, "itunes:summary"); + const podcastGUID = getXPathString(episode, "//guid"); + + return ( + + {image && } + + {title} + {description} + + + ); +} + +export default function EpisodeView() { + const { guid, episode } = useParams(); + const [search] = useSearchParams(); + const url = search.get("feed") || search.get("url"); + + if (!url || !guid || !episode) return ; + + const { xml } = useFeedXML(url); + if (!xml) return ; + + let episodeXml: Element | undefined = undefined; + const items = getXPathElements(xml, "//item"); + if (Number.isFinite(parseInt(episode))) { + episodeXml = items[parseInt(episode)]; + } else { + episodeXml = items.find((item) => getXPathString(item, "guid") === episode); + } + if (!episodeXml) throw new Error(`Cant find episode ${episode}`); + + return ; +} diff --git a/src/views/podcasts/podcast/index.tsx b/src/views/podcasts/podcast/index.tsx new file mode 100644 index 000000000..6acd908ff --- /dev/null +++ b/src/views/podcasts/podcast/index.tsx @@ -0,0 +1,106 @@ +import { Navigate, useParams, useSearchParams } from "react-router-dom"; +import { + FeedPointer, + getPodcastDescription, + getPodcastImageURL, + getPodcastItems, + getPodcastPeople, + getPodcastTitle, + getXPathString, +} from "../../../helpers/nostr/podcasts"; +import VerticalPageLayout from "../../../components/vertical-page-layout"; +import useFeedXML from "../../../hooks/use-feed-xml"; +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Avatar, + AvatarGroup, + Box, + ButtonGroup, + CloseButton, + Flex, + Heading, + IconButton, + Image, + Link, + Spinner, + Text, +} from "@chakra-ui/react"; +import { useMemo } from "react"; +import EpisodeCard from "../components/episode-card"; +import Rss01 from "../../../components/icons/rss-01"; + +function PodcastPage({ pointer, xml }: { pointer: FeedPointer; xml: Document }) { + const image = getPodcastImageURL(xml); + const episodes = useMemo(() => getPodcastItems(xml), [xml]); + + const people = getPodcastPeople(xml); + + return ( + + + + + {getPodcastTitle(xml)} + {getPodcastDescription(xml)} + {people.length > 0 && ( + + {people.map((person) => ( + + ))} + + )} + + } aria-label="Open RSS" /> + + + + + Episodes + {episodes.map((episode, i) => ( + + ))} + + ); +} + +export default function PodcastView() { + const { guid } = useParams(); + const [search] = useSearchParams(); + const url = search.get("feed") || search.get("url"); + + if (!guid || !url) return ; + + const pointer: FeedPointer = { + guid, + url: new URL(url), + }; + + const { xml, loading, error } = useFeedXML(url, true); + + if (error) + return ( + + + + Error! + {error.message} + + + ); + + if (loading || !xml) + return ( + + + + Loading... + {url} + + + ); + + return ; +} diff --git a/src/views/settings/post/index.tsx b/src/views/settings/post/index.tsx index eef95f88a..c75a01776 100644 --- a/src/views/settings/post/index.tsx +++ b/src/views/settings/post/index.tsx @@ -85,11 +85,11 @@ export default function PostSettings() { - + Add client tag localSettings.addClientTag.next(!localSettings.addClientTag.value)} />