From 038d342ad11fbf078ed0cdc4c9008d9e03deeff7 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 30 Jun 2023 13:32:25 -0500 Subject: [PATCH 1/5] truncate open graph card description --- .changeset/silent-buckets-joke.md | 5 +++++ src/components/open-graph-card.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/silent-buckets-joke.md diff --git a/.changeset/silent-buckets-joke.md b/.changeset/silent-buckets-joke.md new file mode 100644 index 000000000..b2263f1c5 --- /dev/null +++ b/.changeset/silent-buckets-joke.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +truncate open graph card description diff --git a/src/components/open-graph-card.tsx b/src/components/open-graph-card.tsx index 8da40c248..99347758f 100644 --- a/src/components/open-graph-card.tsx +++ b/src/components/open-graph-card.tsx @@ -24,7 +24,7 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit - {data.ogDescription || data.dcDescription} + {data.ogDescription || data.dcDescription} {link} From 7a339ae03d7f9bcdc7e1d60afcad37ae66e795b9 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 30 Jun 2023 14:30:53 -0500 Subject: [PATCH 2/5] cache timelines --- .changeset/soft-lions-cry.md | 5 ++++ src/app.tsx | 20 ++++++-------- src/classes/timeline-loader.ts | 19 +++++++------ src/classes/user-timeline.ts | 8 ------ src/components/user-avatar.tsx | 2 +- src/{services => helpers}/identicon.ts | 0 src/helpers/nostr-event.ts | 5 ++-- src/hooks/use-timeline-loader.ts | 38 ++++++++++++-------------- src/services/timeline-cache.ts | 32 ++++++++++++++++++++++ src/services/user-timeline.ts | 33 ---------------------- src/views/home/global-tab.tsx | 3 +- src/views/user/media.tsx | 27 ++++++++---------- src/views/user/notes.tsx | 33 +++++++++------------- 13 files changed, 102 insertions(+), 123 deletions(-) create mode 100644 .changeset/soft-lions-cry.md delete mode 100644 src/classes/user-timeline.ts rename src/{services => helpers}/identicon.ts (100%) create mode 100644 src/services/timeline-cache.ts delete mode 100644 src/services/user-timeline.ts diff --git a/.changeset/soft-lions-cry.md b/.changeset/soft-lions-cry.md new file mode 100644 index 000000000..b573a82e8 --- /dev/null +++ b/.changeset/soft-lions-cry.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +cache timelines diff --git a/src/app.tsx b/src/app.tsx index f7b554e5f..bc120649b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -37,18 +37,14 @@ import UserAboutTab from "./views/user/about"; // code split search view because QrScanner library is 400kB const SearchView = React.lazy(() => import("./views/search")); -const RootPage = () => { - console.log(useLocation()); - - return ( - - - }> - - - - ); -}; +const RootPage = () => ( + + + }> + + + +); const router = createHashRouter([ { diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index 8ef9e9fe1..bf945e377 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -77,8 +77,8 @@ class RelayTimelineLoader { export class TimelineLoader { cursor = dayjs().unix(); - query: NostrQuery; - relays: string[]; + query?: NostrQuery; + relays: string[] = []; events = new PersistentSubject([]); timeline = new PersistentSubject([]); @@ -92,14 +92,9 @@ export class TimelineLoader { private relayTimelineLoaders = new Map(); - constructor(relays: string[], query: NostrQuery, name?: string) { - this.query = query; - this.relays = relays; - - this.subscription = new NostrMultiSubscription(relays, { ...query, limit: BLOCK_SIZE / 2 }, name); + constructor(name?: string) { + this.subscription = new NostrMultiSubscription([], undefined, name); this.subscription.onEvent.subscribe(this.handleEvent, this); - - this.createLoaders(); } private seenEvents = new Set(); @@ -115,6 +110,8 @@ export class TimelineLoader { } private createLoaders() { + if (!this.query) return; + for (const relay of this.relays) { if (!this.relayTimelineLoaders.has(relay)) { const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name); @@ -137,6 +134,8 @@ export class TimelineLoader { } setRelays(relays: string[]) { + if (this.relays.sort().join("|") === relays.sort().join("|")) return; + // remove loaders this.removeLoaders((loader) => !relays.includes(loader.relay)); @@ -147,6 +146,8 @@ export class TimelineLoader { this.updateComplete(); } setQuery(query: NostrQuery) { + if (JSON.stringify(this.query) === JSON.stringify(query)) return; + this.removeLoaders(); this.query = query; diff --git a/src/classes/user-timeline.ts b/src/classes/user-timeline.ts deleted file mode 100644 index 556e814dc..000000000 --- a/src/classes/user-timeline.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { truncatedId } from "../helpers/nostr-event"; -import { TimelineLoader } from "./timeline-loader"; - -export default class UserTimeline extends TimelineLoader { - constructor(pubkey: string) { - super([], { authors: [pubkey], kinds: [1, 6] }, truncatedId(pubkey) + "-timeline"); - } -} diff --git a/src/components/user-avatar.tsx b/src/components/user-avatar.tsx index b9148e818..0a49a2e95 100644 --- a/src/components/user-avatar.tsx +++ b/src/components/user-avatar.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { Avatar, AvatarProps } from "@chakra-ui/react"; import { useUserMetadata } from "../hooks/use-user-metadata"; import { useAsync } from "react-use"; -import { getIdenticon } from "../services/identicon"; +import { getIdenticon } from "../helpers/identicon"; import { safeUrl } from "../helpers/parse"; import appSettings from "../services/app-settings"; import useSubject from "../hooks/use-subject"; diff --git a/src/services/identicon.ts b/src/helpers/identicon.ts similarity index 100% rename from src/services/identicon.ts rename to src/helpers/identicon.ts diff --git a/src/helpers/nostr-event.ts b/src/helpers/nostr-event.ts index c3095b34b..bc4d1fdc2 100644 --- a/src/helpers/nostr-event.ts +++ b/src/helpers/nostr-event.ts @@ -17,8 +17,9 @@ export function isRepost(event: NostrEvent | DraftNostrEvent) { return event.kind === 6 || (match && match[0].length === event.content.length); } -export function truncatedId(id: string, keep = 6) { - return id.substring(0, keep) + "..." + id.substring(id.length - keep); +export function truncatedId(str: string, keep = 6) { + if (str.length < keep * 2 + 3) return str; + return str.substring(0, keep) + "..." + str.substring(str.length - keep); } /** diff --git a/src/hooks/use-timeline-loader.ts b/src/hooks/use-timeline-loader.ts index ab3579b48..db8a01d4c 100644 --- a/src/hooks/use-timeline-loader.ts +++ b/src/hooks/use-timeline-loader.ts @@ -1,48 +1,44 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo } from "react"; import { useUnmount } from "react-use"; -import { TimelineLoader } from "../classes/timeline-loader"; import { NostrQuery } from "../types/nostr-query"; import { NostrEvent } from "../types/nostr-event"; +import timelineCacheService from "../services/timeline-cache"; type Options = { enabled?: boolean; eventFilter?: (event: NostrEvent) => boolean; cursor?: number; - name?: string; }; export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) { - if (opts && !opts.name) opts.name = key; - - const ref = useRef(null); - const loader = (ref.current = ref.current || new TimelineLoader(relays, query, opts?.name)); + const timeline = useMemo(() => timelineCacheService.createTimeline(key), [key]); useEffect(() => { - loader.setQuery(query); - }, [JSON.stringify(query)]); + timeline.setQuery(query); + }, [timeline, JSON.stringify(query)]); useEffect(() => { - loader.setRelays(relays); - }, [relays.join("|")]); + timeline.setRelays(relays); + }, [timeline, relays.join("|")]); useEffect(() => { - loader.setFilter(opts?.eventFilter); - }, [opts?.eventFilter]); + timeline.setFilter(opts?.eventFilter); + }, [timeline, opts?.eventFilter]); useEffect(() => { if (opts?.cursor !== undefined) { - loader.setCursor(opts.cursor); + timeline.setCursor(opts.cursor); } - }, [opts?.cursor]); + }, [timeline, opts?.cursor]); const enabled = opts?.enabled ?? true; useEffect(() => { if (enabled) { - loader.setQuery(query); - loader.open(); - } else loader.close(); - }, [enabled]); + timeline.setQuery(query); + timeline.open(); + } else timeline.close(); + }, [timeline, enabled]); useUnmount(() => { - loader.close(); + timeline.close(); }); - return loader; + return timeline; } diff --git a/src/services/timeline-cache.ts b/src/services/timeline-cache.ts new file mode 100644 index 000000000..3e7b21c96 --- /dev/null +++ b/src/services/timeline-cache.ts @@ -0,0 +1,32 @@ +import { TimelineLoader } from "../classes/timeline-loader"; + +const MAX_CACHE = 4; + +class TimelineCacheService { + private timelines = new Map(); + private cacheQueue: string[] = []; + + createTimeline(key: string) { + let timeline = this.timelines.get(key); + if (!timeline) { + timeline = new TimelineLoader(key); + this.timelines.set(key, timeline); + } + + this.cacheQueue = this.cacheQueue.filter((p) => p !== key).concat(key); + while (this.cacheQueue.length > MAX_CACHE) { + this.cacheQueue.shift(); + } + + return timeline; + } +} + +const timelineCacheService = new TimelineCacheService(); + +if (import.meta.env.DEV) { + //@ts-ignore + window.timelineCacheService = timelineCacheService; +} + +export default timelineCacheService; diff --git a/src/services/user-timeline.ts b/src/services/user-timeline.ts deleted file mode 100644 index 6e9c0fb75..000000000 --- a/src/services/user-timeline.ts +++ /dev/null @@ -1,33 +0,0 @@ -import UserTimeline from "../classes/user-timeline"; - -const MAX_CACHE = 4; - -class UserTimelineService { - timelines = new Map(); - - cacheQueue: string[] = []; - - getTimeline(pubkey: string) { - let timeline = this.timelines.get(pubkey); - if (!timeline) { - timeline = new UserTimeline(pubkey); - this.timelines.set(pubkey, timeline); - } - - this.cacheQueue = this.cacheQueue.filter((p) => p !== pubkey).concat(pubkey); - while (this.cacheQueue.length > MAX_CACHE) { - this.cacheQueue.shift(); - } - - return timeline; - } -} - -const userTimelineService = new UserTimelineService(); - -if (import.meta.env.DEV) { - //@ts-ignore - window.userTimelineService = userTimelineService; -} - -export default userTimelineService; diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index 257e893fa..39fb8c0d2 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -33,9 +33,8 @@ export default function GlobalTab() { }, [showReplies] ); - const timeline = useTimelineLoader( - [`global`, ...selectedRelay].join(","), + [`global`, selectedRelay].join(","), selectedRelay ? [selectedRelay] : [], { kinds: [1] }, { eventFilter } diff --git a/src/views/user/media.tsx b/src/views/user/media.tsx index a28f68928..8aa9dfbe5 100644 --- a/src/views/user/media.tsx +++ b/src/views/user/media.tsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { Box, Flex, Grid, IconButton } from "@chakra-ui/react"; import { useNavigate, useOutletContext } from "react-router-dom"; -import { useMount, useUnmount } from "react-use"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; import { matchImageUrls } from "../../helpers/regexp"; import { useIsMobile } from "../../hooks/use-is-mobile"; @@ -9,11 +8,12 @@ import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-g import { ExternalLinkIcon } from "../../components/icons"; import { getSharableNoteId } from "../../helpers/nip19"; import useSubject from "../../hooks/use-subject"; -import userTimelineService from "../../services/user-timeline"; import { NostrEvent } from "../../types/nostr-event"; import TimelineActionAndStatus from "../../components/timeline-action-and-status"; import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import { truncatedId } from "../../helpers/nostr-event"; type ImagePreview = { eventId: string; src: string; index: number }; const matchAllImages = new RegExp(matchImageUrls, "ig"); @@ -49,22 +49,19 @@ const UserMediaTab = () => { const { pubkey } = useOutletContext() as { pubkey: string }; const contextRelays = useAdditionalRelayContext(); - const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]); - const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []); - useEffect(() => { - timeline.setFilter(eventFilter); - }, [timeline, eventFilter]); + const timeline = useTimelineLoader( + truncatedId(pubkey) + "-notes", + contextRelays, + { + authors: [pubkey], + kinds: [1, 6], + }, + { eventFilter } + ); const events = useSubject(timeline.timeline); - useEffect(() => { - timeline.setRelays(contextRelays); - }, [timeline, contextRelays.join("|")]); - - useMount(() => timeline.open()); - useUnmount(() => timeline.close()); - const images = useMemo(() => { var images: { eventId: string; src: string; index: number }[] = []; diff --git a/src/views/user/notes.tsx b/src/views/user/notes.tsx index afecfc459..e259ecb7e 100644 --- a/src/views/user/notes.tsx +++ b/src/views/user/notes.tsx @@ -1,20 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useRef } from "react"; import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react"; import { useOutletContext } from "react-router-dom"; -import { Note } from "../../components/note"; -import RepostNote from "../../components/repost-note"; -import { isReply, isRepost } from "../../helpers/nostr-event"; +import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event"; import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; -import userTimelineService from "../../services/user-timeline"; -import useSubject from "../../hooks/use-subject"; -import { useMount, useUnmount } from "react-use"; import { RelayIconStack } from "../../components/relay-icon-stack"; import { NostrEvent } from "../../types/nostr-event"; import TimelineActionAndStatus from "../../components/timeline-action-and-status"; import IntersectionObserverProvider from "../../providers/intersection-observer"; -import { TimelineLoader } from "../../classes/timeline-loader"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import GenericNoteTimeline from "../../components/generric-note-timeline"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; const UserNotesTab = () => { const { pubkey } = useOutletContext() as { pubkey: string }; @@ -23,9 +18,6 @@ const UserNotesTab = () => { const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure(); const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure(); - const scrollBox = useRef(null); - - const timeline = useMemo(() => userTimelineService.getTimeline(pubkey), [pubkey]); const eventFilter = useCallback( (event: NostrEvent) => { if (!showReplies && isReply(event)) return false; @@ -34,16 +26,17 @@ const UserNotesTab = () => { }, [showReplies, hideReposts] ); - useEffect(() => { - timeline.setFilter(eventFilter); - }, [timeline, eventFilter]); - useEffect(() => { - timeline.setRelays(readRelays); - }, [timeline, readRelays.join("|")]); - - useMount(() => timeline.open()); - useUnmount(() => timeline.close()); + const timeline = useTimelineLoader( + truncatedId(pubkey) + "-notes", + readRelays, + { + authors: [pubkey], + kinds: [1, 6], + }, + { eventFilter } + ); + const scrollBox = useRef(null); const callback = useTimelineCurserIntersectionCallback(timeline); return ( From 593ad6bdb2b532eb89c5f176339f200c0a191ed1 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 30 Jun 2023 14:52:26 -0500 Subject: [PATCH 3/5] show type of account on account picker --- .changeset/grumpy-apes-tell.md | 5 ++++ src/components/account-info-badge.tsx | 27 +++++++++++++++++++++ src/components/page/account-switcher.tsx | 15 +++++++----- src/hooks/use-shareable-profile-id.ts | 4 +-- src/views/login/components/account-card.tsx | 19 +++++++++------ src/views/login/start.tsx | 2 +- src/views/user/about.tsx | 17 ++++++++++--- 7 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 .changeset/grumpy-apes-tell.md create mode 100644 src/components/account-info-badge.tsx diff --git a/.changeset/grumpy-apes-tell.md b/.changeset/grumpy-apes-tell.md new file mode 100644 index 000000000..e6a4d56e6 --- /dev/null +++ b/.changeset/grumpy-apes-tell.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +show type of account on account picker diff --git a/src/components/account-info-badge.tsx b/src/components/account-info-badge.tsx new file mode 100644 index 000000000..117aae938 --- /dev/null +++ b/src/components/account-info-badge.tsx @@ -0,0 +1,27 @@ +import { Badge, BadgeProps } from "@chakra-ui/react"; +import { Account } from "../services/account"; + +export default function AccountInfoBadge({ account, ...props }: BadgeProps & { account: Account }) { + if (account.useExtension) { + return ( + + extension + + ); + } + if (account.secKey) { + return ( + + nsec + + ); + } + if (account.readonly) { + return ( + + read-only + + ); + } + return null; +} diff --git a/src/components/page/account-switcher.tsx b/src/components/page/account-switcher.tsx index 935b106e0..dce148950 100644 --- a/src/components/page/account-switcher.tsx +++ b/src/components/page/account-switcher.tsx @@ -15,12 +15,14 @@ import { import { getUserDisplayName } from "../../helpers/user-metadata"; import useSubject from "../../hooks/use-subject"; import { useUserMetadata } from "../../hooks/use-user-metadata"; -import accountService from "../../services/account"; +import accountService, { Account } from "../../services/account"; import { AddIcon } from "../icons"; import { UserAvatar } from "../user-avatar"; import { useLocation, useNavigate } from "react-router-dom"; +import AccountInfoBadge from "../account-info-badge"; -function AccountItem({ pubkey }: { pubkey: string }) { +function AccountItem({ account }: { account: Account }) { + const pubkey = account.pubkey; const metadata = useUserMetadata(pubkey, []); const accord = useAccordionContext(); @@ -32,9 +34,10 @@ function AccountItem({ pubkey }: { pubkey: string }) { return ( - - {getUserDisplayName(metadata, pubkey)} - + + {getUserDisplayName(metadata, pubkey)} + + } aria-label="Remove Account" @@ -60,7 +63,7 @@ export function AccountSwitcherList() { return ( {otherAccounts.map((account) => ( - + ))} + ); } From 0c92da8c98c1feae4921a4ce995d6815941a161e Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sat, 1 Jul 2023 14:39:19 -0500 Subject: [PATCH 4/5] 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" From acb09e6dda83f1ee9c5a822b587befeb6098e92a Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sat, 1 Jul 2023 14:41:05 -0500 Subject: [PATCH 5/5] code split stream views --- src/app.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index fa492fe7f..85ab16fdd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -34,9 +34,9 @@ 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 LiveStreamsTab = React.lazy(() => import("./views/home/streams")); +const StreamView = React.lazy(() => import("./views/home/streams/stream")); const SearchView = React.lazy(() => import("./views/search")); const RootPage = () => (