add streaming views

This commit is contained in:
hzrd149 2023-07-01 14:39:19 -05:00
parent 593ad6bdb2
commit 0c92da8c98
28 changed files with 895 additions and 33 deletions

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add views for watching streams

@ -19,6 +19,7 @@
"cheerio": "^1.0.0-rc.12",
"dayjs": "^1.11.8",
"framer-motion": "^7.10.3",
"hls.js": "^1.4.7",
"idb": "^7.1.1",
"identicon.js": "^2.3.3",
"light-bolt11-decoder": "^3.0.0",

@ -34,6 +34,8 @@ import UserMediaTab from "./views/user/media";
import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about";
import LiveStreamsTab from "./views/home/streams";
import StreamView from "./views/home/streams/stream";
// code split search view because QrScanner library is 400kB
const SearchView = React.lazy(() => import("./views/search"));
@ -57,6 +59,7 @@ const router = createHashRouter([
{ path: "nsec", element: <LoginNsecView /> },
],
},
{ path: "streams/:naddr", element: <StreamView /> },
{
path: "/",
element: <RootPage />,
@ -102,6 +105,10 @@ const router = createHashRouter([
children: [
{ path: "", element: <FollowingTab /> },
{ path: "following", element: <FollowingTab /> },
{
path: "streams",
element: <LiveStreamsTab />,
},
{ path: "global", element: <GlobalTab /> },
],
},

@ -7,12 +7,14 @@ import RawValue from "./raw-value";
import RawJson from "./raw-json";
import { useSharableProfileId } from "../../hooks/use-shareable-profile-id";
import userRelaysService from "../../services/user-relays";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
export default function UserDebugModal({ pubkey, ...props }: { pubkey: string } & Omit<ModalProps, "children">) {
const npub = useMemo(() => normalizeToBech32(pubkey, Bech32Prefix.Pubkey), [pubkey]);
const metadata = useUserMetadata(pubkey);
const nprofile = useSharableProfileId(pubkey);
const relays = userRelaysService.requester.getSubject(pubkey).value;
const tipMetadata = useUserLNURLMetadata(pubkey);
return (
<Modal {...props}>
@ -25,6 +27,7 @@ export default function UserDebugModal({ pubkey, ...props }: { pubkey: string }
{npub && <RawValue heading="npub" value={npub} />}
<RawValue heading="nprofile" value={nprofile} />
<RawJson heading="Parsed Metadata (kind 0)" json={metadata} />
<RawJson heading="LNURL metadata" json={tipMetadata.metadata} />
{relays && <RawJson heading="Relay List (kind 10002)" json={relays} />}
</Flex>
</ModalBody>

@ -0,0 +1,19 @@
import React from "react";
import { EmbedableContent } from "../helpers/embeds";
import { Text } from "@chakra-ui/react";
export default function EmbeddedContent({ content }: { content: EmbedableContent }) {
return (
<>
{content.map((part, i) =>
typeof part === "string" ? (
<Text as="span" key={"part-" + i}>
{part}
</Text>
) : (
React.cloneElement(part, { key: "part-" + i })
)
)}
</>
);
}

@ -0,0 +1,92 @@
import {
Button,
Flex,
IconButton,
Input,
Modal,
ModalBody,
ModalContent,
ModalOverlay,
ModalProps,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons";
import QrCodeSvg from "./qr-code-svg";
import { CopyIconButton } from "./copy-icon-button";
import { useIsMobile } from "../hooks/use-is-mobile";
export default function InvoiceModal({
invoice,
onClose,
onPaid,
...props
}: Omit<ModalProps, "children"> & { invoice: string; onPaid: () => void }) {
const isMobile = useIsMobile();
const toast = useToast();
const showQr = useDisclosure();
const payWithWebLn = async (invoice: string) => {
if (window.webln && invoice) {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
if (onPaid) onPaid();
onClose();
}
};
const payWithApp = async (invoice: string) => {
window.open("lightning:" + invoice);
const listener = () => {
if (document.visibilityState === "visible") {
if (onPaid) onPaid();
onClose();
document.removeEventListener("visibilitychange", listener);
}
};
setTimeout(() => {
document.addEventListener("visibilitychange", listener);
}, 1000 * 2);
};
return (
<Modal onClose={onClose} {...props}>
<ModalOverlay />
<ModalContent>
<ModalBody padding="4">
<Flex gap="4" direction="column">
{showQr.isOpen && <QrCodeSvg content={invoice} />}
<Flex gap="2">
<Input value={invoice} readOnly />
<IconButton
icon={<QrCodeIcon />}
aria-label="Show QrCode"
onClick={showQr.onToggle}
variant="solid"
size="md"
/>
<CopyIconButton text={invoice} aria-label="Copy Invoice" variant="solid" size="md" />
</Flex>
<Flex gap="2">
{window.webln && (
<Button onClick={() => payWithWebLn(invoice)} flex={1} variant="solid" size="md">
Pay with WebLN
</Button>
)}
<Button
leftIcon={<ExternalLinkIcon />}
onClick={() => payWithApp(invoice)}
flex={1}
variant="solid"
size="md"
>
Open App
</Button>
</Flex>
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}

@ -0,0 +1,65 @@
import { Badge, Flex, FlexProps } from "@chakra-ui/react";
import Hls from "hls.js";
import { useEffect, useRef, useState } from "react";
export enum VideoStatus {
Online = "online",
Offline = "offline",
}
// copied from zap.stream
export function LiveVideoPlayer({
stream,
autoPlay,
poster,
...props
}: FlexProps & { stream?: string; autoPlay?: boolean; poster?: string }) {
const video = useRef<HTMLVideoElement>(null);
const [status, setStatus] = useState<VideoStatus>();
useEffect(() => {
if (stream && video.current && !video.current.src && Hls.isSupported()) {
try {
const hls = new Hls();
hls.loadSource(stream);
hls.attachMedia(video.current);
hls.on(Hls.Events.ERROR, (event, data) => {
const errorType = data.type;
if (errorType === Hls.ErrorTypes.NETWORK_ERROR && data.fatal) {
hls.stopLoad();
hls.detachMedia();
setStatus(VideoStatus.Offline);
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setStatus(VideoStatus.Online);
});
return () => hls.destroy();
} catch (e) {
console.error(e);
setStatus(VideoStatus.Offline);
}
}
}, [video, stream]);
return (
<Flex justifyContent="center" alignItems="center" {...props} position="relative">
<Badge
position="absolute"
top="4"
left="4"
fontSize="1.2rem"
colorScheme={status === VideoStatus.Offline ? "red" : undefined}
>
{status}
</Badge>
<video
ref={video}
controls={status === VideoStatus.Online}
autoPlay={autoPlay}
poster={poster}
style={{ maxHeight: "100%", maxWidth: "100%", width: "100%" }}
/>
</Flex>
);
}

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Box, Text } from "@chakra-ui/react";
import { Box } from "@chakra-ui/react";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import styled from "@emotion/styled";
import { useExpand } from "./expanded";
@ -23,6 +23,7 @@ import {
import { ImageGalleryProvider } from "../image-gallery";
import { useTrusted } from "./trust";
import { renderRedditUrl } from "../embed-types/reddit";
import EmbeddedContent from "../embeded-content";
function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) {
let content: EmbedableContent = [event.content.trim()];
@ -99,15 +100,7 @@ export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps)
px="2"
>
<div ref={ref}>
{content.map((part, i) =>
typeof part === "string" ? (
<Text as="span" key={"part-" + i}>
{part}
</Text>
) : (
React.cloneElement(part, { key: "part-" + i })
)
)}
<EmbeddedContent content={content} />
</div>
{showOverlay && <GradientOverlay onClick={expand?.onExpand} />}
</Box>

@ -34,6 +34,8 @@ import { CopyIconButton } from "./copy-icon-button";
import { useIsMobile } from "../hooks/use-is-mobile";
import appSettings from "../services/app-settings";
import useSubject from "../hooks/use-subject";
import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata";
import { requestZapInvoice } from "../helpers/zaps";
type FormValues = {
amount: number;
@ -79,11 +81,7 @@ export default function ZapModal({
},
});
const tipAddress = metadata?.lud06 || metadata?.lud16;
const { value: lnurlMetadata } = useAsync(
async () => (tipAddress ? lnurlMetadataService.requestMetadata(tipAddress) : undefined),
[tipAddress]
);
const { metadata: lnurlMetadata, address: tipAddress } = useUserLNURLMetadata(pubkey);
const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey;
const actionName = canZap ? "Zap" : "Tip";
@ -110,18 +108,8 @@ export default function ZapModal({
const signed = await requestSignature(zapRequest);
if (signed) {
const callbackUrl = new URL(lnurlMetadata.callback);
callbackUrl.searchParams.append("amount", String(amountInMilisat));
callbackUrl.searchParams.append("nostr", JSON.stringify(signed));
const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json());
if (payRequest as string) {
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== amountInMilisat) throw new Error("incorrect amount");
payInvoice(payRequest);
} else throw new Error("Failed to get invoice");
const payRequest = await requestZapInvoice(signed, lnurlMetadata.callback);
payInvoice(payRequest);
}
} else {
const callbackUrl = new URL(lnurlMetadata.callback);

@ -0,0 +1,76 @@
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { unique } from "../array";
export type ParsedStream = {
event: NostrEvent;
author: string;
title: string;
summary?: string;
image?: string;
updated: number;
status: "live" | "ended" | string;
starts?: number;
ends?: number;
identifier: string;
tags: string[];
streaming: string;
};
export function parseStreamEvent(stream: NostrEvent): ParsedStream {
const title = stream.tags.find((t) => t[0] === "title")?.[1];
const summary = stream.tags.find((t) => t[0] === "summary")?.[1];
const image = stream.tags.find((t) => t[0] === "image")?.[1];
const starts = stream.tags.find((t) => t[0] === "starts")?.[1];
const endsTag = stream.tags.find((t) => t[0] === "ends")?.[1];
const streaming = stream.tags.find((t) => t[0] === "streaming")?.[1];
const identifier = stream.tags.find((t) => t[0] === "d")?.[1];
const startTime = starts ? parseInt(starts) : stream.created_at;
const endTime = endsTag ? parseInt(endsTag) : dayjs(startTime).add(4, "hour").unix();
if (!title) throw new Error("missing title");
if (!identifier) throw new Error("missing identifier");
if (!streaming) throw new Error("missing streaming");
let status = stream.tags.find((t) => t[0] === "status")?.[1] || "ended";
if (endTime > dayjs().unix()) {
status = "ended";
}
// if the stream has not been updated in a day consider it ended
if (stream.created_at < dayjs().subtract(1, "day").unix()) {
status = "ended";
}
const tags = unique(stream.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1] as string));
return {
author: stream.pubkey,
event: stream,
updated: stream.created_at,
streaming,
tags,
title,
summary,
image,
status,
starts: startTime,
ends: endTime,
identifier,
};
}
export function getATag(stream: ParsedStream) {
return `${stream.event.kind}:${stream.author}:${stream.starts}`;
}
export function buildChatMessage(stream: ParsedStream, content: string) {
const template: DraftNostrEvent = {
tags: [["a", getATag(stream)]],
content,
created_at: dayjs().unix(),
kind: 1311,
};
return template;
}

@ -87,4 +87,22 @@ function cachedParseZapEvent(event: NostrEvent) {
return result;
}
export async function requestZapInvoice(zapRequest: NostrEvent, lnurl: string) {
const amount = zapRequest.tags.find((t) => t[0] === "amount")?.[1];
if (!amount) throw new Error("missing amount");
const callbackUrl = new URL(lnurl);
callbackUrl.searchParams.append("amount", amount);
callbackUrl.searchParams.append("nostr", JSON.stringify(zapRequest));
const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json());
if (payRequest as string) {
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== parseInt(amount)) throw new Error("incorrect amount");
return payRequest as string;
} else throw new Error("Failed to get invoice");
}
export { cachedParseZapEvent as parseZapEvent };

@ -0,0 +1,14 @@
import { useAsync } from "react-use";
import { useUserMetadata } from "./use-user-metadata";
import lnurlMetadataService from "../services/lnurl-metadata";
export default function useUserLNURLMetadata(pubkey: string) {
const userMetadata = useUserMetadata(pubkey);
const address = userMetadata?.lud06 || userMetadata?.lud16;
const { value: metadata } = useAsync(
async () => (address ? lnurlMetadataService.requestMetadata(address) : undefined),
[address]
);
return { metadata, address };
}

@ -3,6 +3,7 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
import { SigningProvider } from "./signing-provider";
import createTheme from "../theme";
import useAppSettings from "../hooks/use-app-settings";
import { InvoiceModalProvider } from "./invoice-modal";
export const Providers = ({ children }: { children: React.ReactNode }) => {
const { primaryColor } = useAppSettings();
@ -10,7 +11,9 @@ export const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<ChakraProvider theme={theme} colorModeManager={localStorageManager}>
<SigningProvider>{children}</SigningProvider>
<SigningProvider>
<InvoiceModalProvider>{children}</InvoiceModalProvider>
</SigningProvider>
</ChakraProvider>
);
};

@ -0,0 +1,52 @@
import React, { useCallback, useContext, useState } from "react";
import InvoiceModal from "../components/invoice-modal";
import createDefer, { Deferred } from "../classes/deferred";
export type InvoiceModalContext = {
requestPay: (invoice: string) => Promise<void>;
};
export const InvoiceModalContext = React.createContext<InvoiceModalContext>({
requestPay: () => {
throw new Error("not setup yet");
},
});
export function useInvoiceModalContext() {
return useContext(InvoiceModalContext);
}
export const InvoiceModalProvider = ({ children }: { children: React.ReactNode }) => {
const [invoice, setInvoice] = useState<string>();
const [defer, setDefer] = useState<Deferred<void>>();
const requestPay = useCallback((invoice: string) => {
const defer = createDefer<void>();
setDefer(defer);
setInvoice(invoice);
return defer;
}, []);
const handleClose = useCallback(() => {
if (defer) {
setInvoice(undefined);
setDefer(undefined);
defer.reject();
}
}, [defer]);
const handlePaid = useCallback(() => {
if (defer) {
setInvoice(undefined);
setDefer(undefined);
defer.resolve();
}
}, [defer]);
return (
<InvoiceModalContext.Provider value={{ requestPay }}>
{children}
{invoice && <InvoiceModal isOpen onClose={handleClose} invoice={invoice} onPaid={handlePaid} />}
</InvoiceModalContext.Provider>
);
};

@ -11,6 +11,7 @@ export type NostrQuery = {
authors?: string[];
kinds?: number[];
"#e"?: string[];
"#a"?: string[];
"#p"?: string[];
"#d"?: string[];
"#t"?: string[];

@ -26,7 +26,7 @@ import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
import GenericNoteTimeline from "../../components/generic-note-timeline";
import { unique } from "../../helpers/array";
function EditableControls() {

@ -13,7 +13,7 @@ import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
import GenericNoteTimeline from "../../components/generic-note-timeline";
function FollowingTabBody() {
const account = useCurrentAccount()!;

@ -10,7 +10,7 @@ import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
import GenericNoteTimeline from "../../components/generic-note-timeline";
export default function GlobalTab() {
useAppTitle("global");

@ -4,7 +4,7 @@ import { Outlet, useMatches, useNavigate } from "react-router-dom";
const tabs = [
{ label: "Following", path: "/following" },
// { label: "Discover", path: "/discover" },
// { label: "Popular", path: "/popular" },
{ label: "Streams", path: "/streams" },
{ label: "Global", path: "/global" },
];

@ -0,0 +1,56 @@
import { Flex, Select } from "@chakra-ui/react";
import { useTimelineLoader } from "../../../hooks/use-timeline-loader";
import { useCallback, useMemo, useRef, useState } from "react";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../../hooks/use-subject";
import StreamCard from "./stream-card";
import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrEvent } from "../../../types/nostr-event";
export default function LiveStreamsTab() {
const readRelays = useReadRelayUrls();
const [filterStatus, setFilterStatus] = useState<string>("live");
const eventFilter = useCallback(
(event: NostrEvent) => {
try {
const parsed = parseStreamEvent(event);
return parsed.status === filterStatus;
} catch (e) {}
return false;
},
[filterStatus]
);
const timeline = useTimelineLoader(`streams`, readRelays, { kinds: [30311] }, { eventFilter });
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
const events = useSubject(timeline.timeline);
const streams = useMemo(() => {
const parsed: ParsedStream[] = [];
for (const event of events) {
try {
parsed.push(parseStreamEvent(event));
} catch (e) {}
}
return parsed.sort((a, b) => b.updated - a.updated);
}, [events]);
return (
<Flex p="2" gap="2" overflow="hidden" direction="column">
<Select maxW="sm" value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value="live">Live</option>
<option value="ended">Ended</option>
</Select>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex gap="2" wrap="wrap" overflowY="auto" overflowX="hidden" ref={scrollBox}>
{streams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} w="sm" />
))}
</Flex>
</IntersectionObserverProvider>
</Flex>
);
}

@ -0,0 +1,12 @@
import { Badge } from "@chakra-ui/react";
import { ParsedStream } from "../../../helpers/nostr/stream";
export default function StreamStatusBadge({ stream }: { stream: ParsedStream }) {
switch (stream.status) {
case "live":
return <Badge colorScheme="green">live</Badge>;
case "ended":
return <Badge colorScheme="red">ended</Badge>;
}
return null;
}

@ -0,0 +1,115 @@
import { useMemo } from "react";
import { ParsedStream } from "../../../helpers/nostr/stream";
import {
Badge,
Button,
ButtonGroup,
Card,
CardBody,
CardFooter,
CardProps,
Divider,
Flex,
Heading,
IconButton,
Image,
Link,
LinkBox,
LinkOverlay,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spacer,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { UserAvatar } from "../../../components/user-avatar";
import { UserLink } from "../../../components/user-link";
import dayjs from "dayjs";
import relayScoreboardService from "../../../services/relay-scoreboard";
import { getEventRelays } from "../../../services/event-relays";
import { nip19 } from "nostr-tools";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import StreamStatusBadge from "./status-badge";
import { CodeIcon } from "../../../components/icons";
import RawValue from "../../../components/debug-modals/raw-value";
import RawJson from "../../../components/debug-modals/raw-json";
export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) {
const { title, summary, starts, identifier, status, image } = stream;
const devModal = useDisclosure();
const naddr = useMemo(() => {
const relays = getEventRelays(stream.event.id).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
return nip19.naddrEncode({
identifier,
relays: onlyTwo,
pubkey: stream.author,
kind: stream.event.kind,
});
}, [identifier]);
return (
<>
<Card {...props}>
<LinkBox as={CardBody} p="2" display="flex" flexDirection="column" gap="2">
{image && <Image src={image} alt={title} borderRadius="lg" />}
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={stream.author} size="sm" />
<Heading size="sm">
<UserLink pubkey={stream.author} />
</Heading>
</Flex>
<Heading size="md">
<LinkOverlay as={RouterLink} to={`/streams/${naddr}`}>
{title}
</LinkOverlay>
</Heading>
<Text>{summary}</Text>
{stream.tags.length > 0 && (
<Flex gap="2" wrap="wrap">
{stream.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</Flex>
)}
<Text>Updated: {dayjs.unix(stream.updated).fromNow()}</Text>
</LinkBox>
<Divider />
<CardFooter p="2" display="flex" gap="2" alignItems="center">
<StreamStatusBadge stream={stream} />
<Spacer />
<IconButton
icon={<CodeIcon />}
aria-label="show raw event"
onClick={devModal.onOpen}
variant="ghost"
size="sm"
/>
</CardFooter>
</Card>
<Modal isOpen={devModal.isOpen} onClose={devModal.onClose} size="6xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Raw event</ModalHeader>
<ModalCloseButton />
<ModalBody overflow="auto" p="4">
<Flex gap="2" direction="column">
<RawValue heading="Event Id" value={stream.event.id} />
<RawValue heading="naddr" value={naddr} />
<RawJson heading="Parsed" json={{ ...stream, event: "Omitted, see JSON below" }} />
<RawJson heading="JSON" json={stream.event} />
</Flex>
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

@ -0,0 +1,39 @@
import { useMemo } from "react";
import { ParsedStream } from "../../../helpers/nostr/stream";
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
import {
embedEmoji,
embedNostrHashtags,
embedNostrLinks,
embedNostrMentions,
renderGenericUrl,
renderImageUrl,
} from "../../../components/embed-types";
import { Box, BoxProps } from "@chakra-ui/react";
import EmbeddedContent from "../../../components/embeded-content";
export default function StreamSummaryContent({ stream, ...props }: BoxProps & { stream: ParsedStream }) {
const content = useMemo(() => {
if (!stream.summary) return null;
let c: EmbedableContent = [stream.summary];
// general
c = embedUrls(c, [renderImageUrl, renderGenericUrl]);
// nostr
c = embedNostrLinks(c);
c = embedNostrMentions(c, stream.event);
c = embedNostrHashtags(c, stream.event);
c = embedEmoji(c, stream.event);
return c;
}, [stream.summary]);
return (
content && (
<Box whiteSpace="pre-wrap" {...props}>
<EmbeddedContent content={content} />
</Box>
)
);
}

@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { Box, Button, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react";
import { Link as RouterLink, useParams, Navigate } from "react-router-dom";
import { ParsedStream, parseStreamEvent } from "../../../../helpers/nostr/stream";
import { nip19 } from "nostr-tools";
import { NostrRequest } from "../../../../classes/nostr-request";
import { useReadRelayUrls } from "../../../../hooks/use-client-relays";
import { unique } from "../../../../helpers/array";
import { LiveVideoPlayer } from "../../../../components/live-video-player";
import StreamChat from "./stream-chat";
import { UserAvatarLink } from "../../../../components/user-avatar-link";
import { UserLink } from "../../../../components/user-link";
import { useIsMobile } from "../../../../hooks/use-is-mobile";
import { AdditionalRelayProvider } from "../../../../providers/additional-relay-context";
import StreamSummaryContent from "../stream-summary-content";
function StreamPage({ stream }: { stream: ParsedStream }) {
const isMobile = useIsMobile();
return (
<Flex
h="full"
overflowX="hidden"
overflowY="auto"
direction={isMobile ? "column" : "row"}
p={isMobile ? 0 : "2"}
gap={isMobile ? 0 : "4"}
>
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}>
<LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" />
<Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}>
<UserAvatarLink pubkey={stream.author} />
<Box>
<Heading size="md">
<UserLink pubkey={stream.author} />
</Heading>
<Text>{stream.title}</Text>
</Box>
<Spacer />
<Button as={RouterLink} to="/streams">
Back
</Button>
</Flex>
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} />
</Flex>
<StreamChat stream={stream} flexGrow={1} maxW={isMobile ? undefined : "lg"} maxH="100vh" flexShrink={0} />
</Flex>
);
}
export default function StreamView() {
const { naddr } = useParams();
if (!naddr) return <Navigate replace to="/streams" />;
const readRelays = useReadRelayUrls();
const [stream, setStream] = useState<ParsedStream>();
const [relays, setRelays] = useState<string[]>([]);
useEffect(() => {
try {
const parsed = nip19.decode(naddr);
if (parsed.type !== "naddr") throw new Error("Invalid stream address");
if (parsed.data.kind !== 30311) throw new Error("Invalid stream kind");
const request = new NostrRequest(unique([...readRelays, ...(parsed.data.relays ?? [])]));
request.onEvent.subscribe((event) => {
setStream(parseStreamEvent(event));
if (parsed.data.relays) setRelays(parsed.data.relays);
});
request.start({ kinds: [parsed.data.kind], "#d": [parsed.data.identifier], authors: [parsed.data.pubkey] });
} catch (e) {
console.log(e);
}
}, [naddr]);
if (!stream) return <Spinner />;
return (
<AdditionalRelayProvider relays={relays}>
<StreamPage stream={stream} />
</AdditionalRelayProvider>
);
}

@ -0,0 +1,216 @@
import { useCallback, useMemo, useRef } from "react";
import dayjs from "dayjs";
import {
Box,
Button,
Card,
CardBody,
CardHeader,
CardProps,
Flex,
Heading,
IconButton,
Input,
Spacer,
Text,
useToast,
} from "@chakra-ui/react";
import { ParsedStream, buildChatMessage, getATag } from "../../../../helpers/nostr/stream";
import { useTimelineLoader } from "../../../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../../../hooks/use-client-relays";
import { useAdditionalRelayContext } from "../../../../providers/additional-relay-context";
import useSubject from "../../../../hooks/use-subject";
import { truncatedId } from "../../../../helpers/nostr-event";
import { UserAvatar } from "../../../../components/user-avatar";
import { UserLink } from "../../../../components/user-link";
import { DraftNostrEvent, NostrEvent } from "../../../../types/nostr-event";
import IntersectionObserverProvider, {
useRegisterIntersectionEntity,
} from "../../../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-timeline-cursor-intersection-callback";
import { embedUrls } from "../../../../helpers/embeds";
import { embedEmoji, renderGenericUrl, renderImageUrl } from "../../../../components/embed-types";
import EmbeddedContent from "../../../../components/embeded-content";
import { useForm } from "react-hook-form";
import { useSigningContext } from "../../../../providers/signing-provider";
import { nostrPostAction } from "../../../../classes/nostr-post-action";
import { useUserRelays } from "../../../../hooks/use-user-relays";
import { RelayMode } from "../../../../classes/relay";
import { unique } from "../../../../helpers/array";
import { LightningIcon } from "../../../../components/icons";
import { parseZapEvent, requestZapInvoice } from "../../../../helpers/zaps";
import { readablizeSats } from "../../../../helpers/bolt11";
import { Kind } from "nostr-tools";
import useUserLNURLMetadata from "../../../../hooks/use-user-lnurl-metadata";
import { useInvoiceModalContext } from "../../../../providers/invoice-modal";
function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
const content = useMemo(() => {
let c = embedUrls([event.content], [renderImageUrl, renderGenericUrl]);
c = embedEmoji(c, event);
return c;
}, [event.content]);
return (
<Flex direction="column" ref={ref}>
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink
pubkey={event.pubkey}
fontWeight="bold"
color={event.pubkey === stream.author ? "rgb(248, 56, 217)" : "cyan"}
/>
<Spacer />
<Text>{dayjs.unix(event.created_at).fromNow()}</Text>
</Flex>
<Box>
<EmbeddedContent content={content} />
</Box>
</Flex>
);
}
function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, zap.id);
const { request, payment } = parseZapEvent(zap);
const content = useMemo(() => {
let c = embedUrls([request.content], [renderImageUrl, renderGenericUrl]);
c = embedEmoji(c, request);
return c;
}, [request.content]);
if (!payment.amount) return null;
return (
<Flex direction="column" borderRadius="md" borderColor="yellow.400" borderWidth="1px" p="2" ref={ref}>
<Flex gap="2">
<LightningIcon color="yellow.400" />
<UserAvatar pubkey={zap.pubkey} size="xs" />
<UserLink pubkey={request.pubkey} fontWeight="bold" color="yellow.400" />
<Text>zapped {readablizeSats(payment.amount / 1000)} sats</Text>
<Spacer />
<Text>{dayjs.unix(request.created_at).fromNow()}</Text>
</Flex>
<Box>
<EmbeddedContent content={content} />
</Box>
</Flex>
);
}
export default function StreamChat({ stream, ...props }: CardProps & { stream: ParsedStream }) {
const toast = useToast();
const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays);
const writeRelays = useUserRelays(stream.author)
.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url);
const timeline = useTimelineLoader(`${truncatedId(stream.event.id)}-chat`, readRelays, {
"#a": [getATag(stream)],
kinds: [1311, 9735],
});
const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
const { requestSignature } = useSigningContext();
const { register, handleSubmit, formState, reset, getValues } = useForm({
defaultValues: { content: "" },
});
const sendMessage = handleSubmit(async (values) => {
try {
const draft = buildChatMessage(stream, values.content);
const signed = await requestSignature(draft);
if (!signed) throw new Error("Failed to sign");
nostrPostAction(unique([...contextRelays, ...writeRelays]), signed);
reset();
} catch (e) {
if (e instanceof Error) toast({ description: e.message });
}
});
const { requestPay } = useInvoiceModalContext();
const zapMetadata = useUserLNURLMetadata(stream.author);
const zapMessage = useCallback(async () => {
try {
if (!zapMetadata.metadata?.callback) throw new Error("bad lnurl endpoint");
const content = getValues().content;
const amount = 100;
const zapRequest: DraftNostrEvent = {
kind: Kind.ZapRequest,
created_at: dayjs().unix(),
content,
tags: [
["p", stream.author],
["a", getATag(stream)],
["relays", ...writeRelays],
["amount", String(amount * 1000)],
],
};
const signed = await requestSignature(zapRequest);
if (!signed) throw new Error("Failed to sign");
const invoice = await requestZapInvoice(signed, zapMetadata.metadata.callback);
await requestPay(invoice);
reset();
} catch (e) {
if (e instanceof Error) toast({ description: e.message });
}
}, [stream]);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Card {...props} overflow="hidden">
<CardHeader py="3">
<Heading size="md">Stream Chat</Heading>
</CardHeader>
<CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}>
<Flex
overflowY="scroll"
overflowX="hidden"
ref={scrollBox}
direction="column-reverse"
flex={1}
px="4"
py="2"
gap="2"
>
{events.map((event) =>
event.kind === 1311 ? (
<ChatMessage key={event.id} event={event} stream={stream} />
) : (
<ZapMessage key={event.id} zap={event} stream={stream} />
)
)}
</Flex>
<Box as="form" borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2" onSubmit={sendMessage}>
<Input placeholder="Message" {...register("content", { required: true })} autoComplete="off" />
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
Send
</Button>
{zapMetadata.metadata?.allowsNostr && (
<IconButton
icon={<LightningIcon color="yellow.400" />}
aria-label="Zap stream"
borderColor="yellow.400"
variant="outline"
onClick={zapMessage}
/>
)}
</Box>
</CardBody>
</Card>
</IntersectionObserverProvider>
);
}

@ -8,7 +8,7 @@ import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generric-note-timeline";
import GenericNoteTimeline from "../../components/generic-note-timeline";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
const UserNotesTab = () => {

@ -4278,6 +4278,11 @@ hey-listen@^1.0.8:
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
hls.js@^1.4.7:
version "1.4.7"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.4.7.tgz#a739d93ad74944eaa52493b6e37d08f042c31041"
integrity sha512-dvwJXLlYES6wb7DR42uuTrio5sUTsIoWbuNeQS4xHMqfVBZ0KAlJlBmjFAo4s20/0XRhsMjWf5bx0kq5Lgvv1w==
hoist-non-react-statics@^3.3.1:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"