From 0c92da8c98c1feae4921a4ce995d6815941a161e Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sat, 1 Jul 2023 14:39:19 -0500 Subject: [PATCH] add streaming views --- .changeset/sharp-sheep-yawn.md | 5 + package.json | 1 + src/app.tsx | 7 + .../debug-modals/user-debug-modal.tsx | 3 + src/components/embeded-content.tsx | 19 ++ ...timeline.tsx => generic-note-timeline.tsx} | 0 src/components/invoice-modal.tsx | 92 ++++++++ src/components/live-video-player.tsx | 65 ++++++ src/components/note/note-contents.tsx | 13 +- src/components/zap-modal.tsx | 22 +- src/helpers/nostr/stream.ts | 76 ++++++ src/helpers/zaps.ts | 18 ++ src/hooks/use-user-lnurl-metadata.ts | 14 ++ src/providers/index.tsx | 5 +- src/providers/invoice-modal.tsx | 52 +++++ src/types/nostr-query.ts | 1 + src/views/hashtag/index.tsx | 2 +- src/views/home/following-tab.tsx | 2 +- src/views/home/global-tab.tsx | 2 +- src/views/home/index.tsx | 2 +- src/views/home/streams/index.tsx | 56 +++++ src/views/home/streams/status-badge.tsx | 12 + src/views/home/streams/stream-card.tsx | 115 ++++++++++ .../home/streams/stream-summary-content.tsx | 39 ++++ src/views/home/streams/stream/index.tsx | 82 +++++++ src/views/home/streams/stream/stream-chat.tsx | 216 ++++++++++++++++++ src/views/user/notes.tsx | 2 +- yarn.lock | 5 + 28 files changed, 895 insertions(+), 33 deletions(-) create mode 100644 .changeset/sharp-sheep-yawn.md create mode 100644 src/components/embeded-content.tsx rename src/components/{generric-note-timeline.tsx => generic-note-timeline.tsx} (100%) create mode 100644 src/components/invoice-modal.tsx create mode 100644 src/components/live-video-player.tsx create mode 100644 src/helpers/nostr/stream.ts create mode 100644 src/hooks/use-user-lnurl-metadata.ts create mode 100644 src/providers/invoice-modal.tsx create mode 100644 src/views/home/streams/index.tsx create mode 100644 src/views/home/streams/status-badge.tsx create mode 100644 src/views/home/streams/stream-card.tsx create mode 100644 src/views/home/streams/stream-summary-content.tsx create mode 100644 src/views/home/streams/stream/index.tsx create mode 100644 src/views/home/streams/stream/stream-chat.tsx diff --git a/.changeset/sharp-sheep-yawn.md b/.changeset/sharp-sheep-yawn.md new file mode 100644 index 000000000..60ec24252 --- /dev/null +++ b/.changeset/sharp-sheep-yawn.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add views for watching streams diff --git a/package.json b/package.json index 004ffece5..e743d7f42 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.tsx b/src/app.tsx index bc120649b..fa492fe7f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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: }, ], }, + { path: "streams/:naddr", element: }, { path: "/", element: , @@ -102,6 +105,10 @@ const router = createHashRouter([ children: [ { path: "", element: }, { path: "following", element: }, + { + path: "streams", + element: , + }, { path: "global", element: }, ], }, diff --git a/src/components/debug-modals/user-debug-modal.tsx b/src/components/debug-modals/user-debug-modal.tsx index b90b74c40..19821c007 100644 --- a/src/components/debug-modals/user-debug-modal.tsx +++ b/src/components/debug-modals/user-debug-modal.tsx @@ -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) { 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 ( @@ -25,6 +27,7 @@ export default function UserDebugModal({ pubkey, ...props }: { pubkey: string } {npub && } + {relays && } diff --git a/src/components/embeded-content.tsx b/src/components/embeded-content.tsx new file mode 100644 index 000000000..f85615a61 --- /dev/null +++ b/src/components/embeded-content.tsx @@ -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" ? ( + + {part} + + ) : ( + React.cloneElement(part, { key: "part-" + i }) + ) + )} + + ); +} diff --git a/src/components/generric-note-timeline.tsx b/src/components/generic-note-timeline.tsx similarity index 100% rename from src/components/generric-note-timeline.tsx rename to src/components/generic-note-timeline.tsx diff --git a/src/components/invoice-modal.tsx b/src/components/invoice-modal.tsx new file mode 100644 index 000000000..684872804 --- /dev/null +++ b/src/components/invoice-modal.tsx @@ -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 & { 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 ( + + + + + + {showQr.isOpen && } + + + } + aria-label="Show QrCode" + onClick={showQr.onToggle} + variant="solid" + size="md" + /> + + + + {window.webln && ( + + )} + + + + + + + ); +} diff --git a/src/components/live-video-player.tsx b/src/components/live-video-player.tsx new file mode 100644 index 000000000..2558a8d1d --- /dev/null +++ b/src/components/live-video-player.tsx @@ -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(null); + const [status, setStatus] = useState(); + + 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 ( + + + {status} + + + ); +} diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx index 96f44dba9..0c16dadd7 100644 --- a/src/components/note/note-contents.tsx +++ b/src/components/note/note-contents.tsx @@ -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" >
- {content.map((part, i) => - typeof part === "string" ? ( - - {part} - - ) : ( - React.cloneElement(part, { key: "part-" + i }) - ) - )} +
{showOverlay && } diff --git a/src/components/zap-modal.tsx b/src/components/zap-modal.tsx index 3ba12eb05..474ed4a68 100644 --- a/src/components/zap-modal.tsx +++ b/src/components/zap-modal.tsx @@ -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); diff --git a/src/helpers/nostr/stream.ts b/src/helpers/nostr/stream.ts new file mode 100644 index 000000000..c890e8829 --- /dev/null +++ b/src/helpers/nostr/stream.ts @@ -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; +} diff --git a/src/helpers/zaps.ts b/src/helpers/zaps.ts index cd01541d4..cc2501340 100644 --- a/src/helpers/zaps.ts +++ b/src/helpers/zaps.ts @@ -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 }; diff --git a/src/hooks/use-user-lnurl-metadata.ts b/src/hooks/use-user-lnurl-metadata.ts new file mode 100644 index 000000000..cbfc782df --- /dev/null +++ b/src/hooks/use-user-lnurl-metadata.ts @@ -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 }; +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index de699762a..9639728bd 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -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 ( - {children} + + {children} + ); }; diff --git a/src/providers/invoice-modal.tsx b/src/providers/invoice-modal.tsx new file mode 100644 index 000000000..512fcf55e --- /dev/null +++ b/src/providers/invoice-modal.tsx @@ -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; +}; + +export const InvoiceModalContext = React.createContext({ + requestPay: () => { + throw new Error("not setup yet"); + }, +}); + +export function useInvoiceModalContext() { + return useContext(InvoiceModalContext); +} + +export const InvoiceModalProvider = ({ children }: { children: React.ReactNode }) => { + const [invoice, setInvoice] = useState(); + const [defer, setDefer] = useState>(); + + const requestPay = useCallback((invoice: string) => { + const defer = createDefer(); + 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 ( + + {children} + {invoice && } + + ); +}; diff --git a/src/types/nostr-query.ts b/src/types/nostr-query.ts index e1d0a202e..58052ee31 100644 --- a/src/types/nostr-query.ts +++ b/src/types/nostr-query.ts @@ -11,6 +11,7 @@ export type NostrQuery = { authors?: string[]; kinds?: number[]; "#e"?: string[]; + "#a"?: string[]; "#p"?: string[]; "#d"?: string[]; "#t"?: string[]; diff --git a/src/views/hashtag/index.tsx b/src/views/hashtag/index.tsx index 057a99f38..43d12399f 100644 --- a/src/views/hashtag/index.tsx +++ b/src/views/hashtag/index.tsx @@ -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() { diff --git a/src/views/home/following-tab.tsx b/src/views/home/following-tab.tsx index 78f7985fd..9c57bcb3d 100644 --- a/src/views/home/following-tab.tsx +++ b/src/views/home/following-tab.tsx @@ -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()!; diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index 39fb8c0d2..d30565394 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -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"); diff --git a/src/views/home/index.tsx b/src/views/home/index.tsx index c93eda09f..adf5d2609 100644 --- a/src/views/home/index.tsx +++ b/src/views/home/index.tsx @@ -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" }, ]; diff --git a/src/views/home/streams/index.tsx b/src/views/home/streams/index.tsx new file mode 100644 index 000000000..d4cedc61b --- /dev/null +++ b/src/views/home/streams/index.tsx @@ -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("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(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 ( + + + + + {streams.map((stream) => ( + + ))} + + + + ); +} diff --git a/src/views/home/streams/status-badge.tsx b/src/views/home/streams/status-badge.tsx new file mode 100644 index 000000000..a0bf4eced --- /dev/null +++ b/src/views/home/streams/status-badge.tsx @@ -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 live; + case "ended": + return ended; + } + return null; +} diff --git a/src/views/home/streams/stream-card.tsx b/src/views/home/streams/stream-card.tsx new file mode 100644 index 000000000..8eda2fe13 --- /dev/null +++ b/src/views/home/streams/stream-card.tsx @@ -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 ( + <> + + + {image && {title}} + + + + + + + + + {title} + + + {summary} + {stream.tags.length > 0 && ( + + {stream.tags.map((tag) => ( + {tag} + ))} + + )} + Updated: {dayjs.unix(stream.updated).fromNow()} + + + + + + } + aria-label="show raw event" + onClick={devModal.onOpen} + variant="ghost" + size="sm" + /> + + + + + + + Raw event + + + + + + + + + + + + + ); +} diff --git a/src/views/home/streams/stream-summary-content.tsx b/src/views/home/streams/stream-summary-content.tsx new file mode 100644 index 000000000..1274a573f --- /dev/null +++ b/src/views/home/streams/stream-summary-content.tsx @@ -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 && ( + + + + ) + ); +} diff --git a/src/views/home/streams/stream/index.tsx b/src/views/home/streams/stream/index.tsx new file mode 100644 index 000000000..ccff8b5a8 --- /dev/null +++ b/src/views/home/streams/stream/index.tsx @@ -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 ( + + + + + + + + + + {stream.title} + + + + + + + + + ); +} + +export default function StreamView() { + const { naddr } = useParams(); + if (!naddr) return ; + + const readRelays = useReadRelayUrls(); + const [stream, setStream] = useState(); + const [relays, setRelays] = useState([]); + + 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 ; + return ( + + + + ); +} diff --git a/src/views/home/streams/stream/stream-chat.tsx b/src/views/home/streams/stream/stream-chat.tsx new file mode 100644 index 000000000..74323ea56 --- /dev/null +++ b/src/views/home/streams/stream/stream-chat.tsx @@ -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(null); + useRegisterIntersectionEntity(ref, event.id); + + const content = useMemo(() => { + let c = embedUrls([event.content], [renderImageUrl, renderGenericUrl]); + c = embedEmoji(c, event); + return c; + }, [event.content]); + + return ( + + + + + + {dayjs.unix(event.created_at).fromNow()} + + + + + + ); +} + +function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) { + const ref = useRef(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 ( + + + + + + zapped {readablizeSats(payment.amount / 1000)} sats + + {dayjs.unix(request.created_at).fromNow()} + + + + + + ); +} + +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(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 ( + + + + Stream Chat + + + + {events.map((event) => + event.kind === 1311 ? ( + + ) : ( + + ) + )} + + + + + {zapMetadata.metadata?.allowsNostr && ( + } + aria-label="Zap stream" + borderColor="yellow.400" + variant="outline" + onClick={zapMessage} + /> + )} + + + + + ); +} diff --git a/src/views/user/notes.tsx b/src/views/user/notes.tsx index e259ecb7e..3945a7ea8 100644 --- a/src/views/user/notes.tsx +++ b/src/views/user/notes.tsx @@ -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 = () => { diff --git a/yarn.lock b/yarn.lock index ac3d46fff..a68e9a64d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"