add prototype podcast views

This commit is contained in:
hzrd149 2025-01-07 16:53:51 -06:00
parent b185b0a6ed
commit c940b42ed3
16 changed files with 596 additions and 4 deletions

View File

@ -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",

9
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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: (
<RequireCurrentAccount>
<Outlet />
</RequireCurrentAccount>
),
children: [
{ path: "", element: <PodcastsHomeView /> },
{ path: ":guid", element: <PodcastView /> },
{ path: ":guid/:episode", element: <EpisodeView /> },
],
},
{
path: "videos",
children: [

View File

@ -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");

View File

@ -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;
}

View File

@ -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 };
}

View File

@ -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<string>,
opts: RequestOptions = {},
) {
const account = useCurrentAccount();
pubkey = pubkey || account?.pubkey;
return useReplaceableEvent(pubkey ? { kind: PODCASTS_LIST_KIND, pubkey } : undefined, additionalRelays, opts);
}

29
src/services/xml-feeds.ts Normal file
View File

@ -0,0 +1,29 @@
import { fetchWithProxy } from "../helpers/request";
class XmlFeedsService {
parser = new DOMParser();
feeds = new Map<string, Document>();
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<Document> {
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;
}

View File

@ -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",

View File

@ -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 (
<Flex as="form" gap="2" direction="column" onSubmit={submit}>
<FormControl>
<FormLabel>Feed URL</FormLabel>
<Input
type="url"
{...register("url", { required: true })}
isRequired
placeholder="https://best-podcast.com/feed.xml"
/>
<FormHelperText>Enter the feed URL of a podcast</FormHelperText>
</FormControl>
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="public" mb="0">
Public subscription
</FormLabel>
<Switch id="public" {...register("public")} isDisabled />
</Flex>
</FormControl>
{url && URL.canParse(url) && (
<ErrorBoundary>
<PodcastFeedCard pointer={{ guid: "", url: new URL(url) }} />
</ErrorBoundary>
)}
<Flex justifyContent="flex-end">
<Button type="submit" isLoading={formState.isLoading} colorScheme="primary">
Add
</Button>
</Flex>
</Flex>
);
}

View File

@ -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 (
<Card as={LinkBox} flexDirection="row" overflow="hidden">
{image && <Image src={image} maxH="24" w="auto" />}
<Flex direction="column" overflow="hidden" w="full">
<CardHeader p="2" alignItems="center">
<Heading size="sm">
<HoverLinkOverlay
as={RouterLink}
to={{ pathname: `/podcasts/${podcastGuid}/${encodeURIComponent(guid)}`, search: search.toString() }}
>
{title}
</HoverLinkOverlay>
</Heading>
</CardHeader>
<CardBody display="block" px="2" pb="2" pt="0" noOfLines={2}>
{description}
</CardBody>
</Flex>
</Card>
);
}

View File

@ -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 (
<Card as={to ? LinkBox : "div"}>
<CardHeader p="4" alignItems="center">
<HoverLinkOverlay as={RouterLink} to={to}>
<Spinner /> Fetching feed...
</HoverLinkOverlay>
</CardHeader>
<CardBody px="4" pb="4" pt="0">
{pointer.url.toString()}
</CardBody>
</Card>
);
const image = getPodcastImageURL(xml);
return (
<Card as={to ? LinkBox : "div"} flexDirection="row" overflow="hidden">
{image && <Image src={image} maxH="24" w="auto" />}
<Flex direction="column" overflow="hidden" w="full">
<CardHeader p="2" alignItems="center">
<Heading size="sm">
<HoverLinkOverlay as={RouterLink} to={to}>
{getPodcastTitle(xml)}
</HoverLinkOverlay>
</Heading>
</CardHeader>
<CardBody display="block" px="2" pb="2" pt="0" noOfLines={2}>
{getPodcastDescription(xml)}
</CardBody>
</Flex>
</Card>
);
}

View File

@ -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 (
<VerticalPageLayout>
<Flex>
<Heading size="lg">Podcasts</Heading>
<Button ms="auto" leftIcon={<Plus boxSize={6} />} colorScheme="primary" onClick={add.onOpen}>
Add
</Button>
</Flex>
<Heading size="md">Subscribed</Heading>
<SimpleGrid columns={{ base: 1, lg: 2 }} gap="2">
{feeds.map((feed) => (
<PodcastFeedCard
key={feed.guid}
pointer={feed}
to={`/podcasts/${feed.guid}?feed=${encodeURIComponent(feed.url.toString())}`}
/>
))}
</SimpleGrid>
{add.isOpen && (
<Modal isOpen={add.isOpen} onClose={add.onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Add feed</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" pb="4" pt="0">
<AddFeedForm onAdded={add.onClose} />
</ModalBody>
</ModalContent>
</Modal>
)}
</VerticalPageLayout>
);
}

View File

@ -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 (
<VerticalPageLayout>
{image && <Image src={image} maxH="24" w="auto" />}
<Flex direction="column" overflow="hidden" w="full">
<Heading size="lg">{title}</Heading>
<Text>{description}</Text>
</Flex>
</VerticalPageLayout>
);
}
export default function EpisodeView() {
const { guid, episode } = useParams();
const [search] = useSearchParams();
const url = search.get("feed") || search.get("url");
if (!url || !guid || !episode) return <Navigate to="/podcasts" />;
const { xml } = useFeedXML(url);
if (!xml) return <Spinner />;
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 <EpisodePage episode={episodeXml} />;
}

View File

@ -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 (
<VerticalPageLayout>
<Flex gap="4">
<Image maxH="32" src={image} />
<Box>
<Heading size="lg">{getPodcastTitle(xml)}</Heading>
<Text>{getPodcastDescription(xml)}</Text>
{people.length > 0 && (
<AvatarGroup>
{people.map((person) => (
<Avatar key={person.name} name={person.name} src={person.image} />
))}
</AvatarGroup>
)}
<ButtonGroup variant="ghost" size="sm">
<IconButton as={Link} href={pointer.url.toString()} isExternal icon={<Rss01 />} aria-label="Open RSS" />
</ButtonGroup>
</Box>
</Flex>
<Heading size="md">Episodes</Heading>
{episodes.map((episode, i) => (
<EpisodeCard key={getXPathString(episode, "guid", true) || i} episode={episode} />
))}
</VerticalPageLayout>
);
}
export default function PodcastView() {
const { guid } = useParams();
const [search] = useSearchParams();
const url = search.get("feed") || search.get("url");
if (!guid || !url) return <Navigate to="/podcasts" />;
const pointer: FeedPointer = {
guid,
url: new URL(url),
};
const { xml, loading, error } = useFeedXML(url, true);
if (error)
return (
<Alert status="error">
<AlertIcon />
<Box>
<AlertTitle>Error!</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Box>
</Alert>
);
if (loading || !xml)
return (
<Alert status="info">
<Spinner mr="4" />
<Box>
<AlertTitle>Loading...</AlertTitle>
<AlertDescription>{url}</AlertDescription>
</Box>
</Alert>
);
return <PodcastPage pointer={pointer} xml={xml} />;
}

View File

@ -85,11 +85,11 @@ export default function PostSettings() {
<FormControl>
<Flex alignItems="center">
<FormLabel htmlFor="autoShowMedia" mb="0">
<FormLabel htmlFor="addClientTag" mb="0">
Add client tag
</FormLabel>
<Switch
id="autoShowMedia"
id="addClientTag"
isChecked={addClientTag}
onChange={() => localSettings.addClientTag.next(!localSettings.addClientTag.value)}
/>