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/.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/.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/.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/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 f7b554e5f..85ab16fdd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -34,21 +34,19 @@ import UserMediaTab from "./views/user/media"; import ToolsHomeView from "./views/tools"; import Nip19ToolsView from "./views/tools/nip19"; import UserAboutTab from "./views/user/about"; -// 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 = () => { - console.log(useLocation()); - - return ( - - - }> - - - - ); -}; +const RootPage = () => ( + + + }> + + + +); const router = createHashRouter([ { @@ -61,6 +59,7 @@ const router = createHashRouter([ { path: "nsec", element: }, ], }, + { path: "streams/:naddr", element: }, { path: "/", element: , @@ -106,6 +105,10 @@ const router = createHashRouter([ children: [ { path: "", element: }, { path: "following", element: }, + { + path: "streams", + element: , + }, { path: "global", element: }, ], }, 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/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/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/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} 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) => ( - + ))} + + + + + + ); +} + +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/login/components/account-card.tsx b/src/views/login/components/account-card.tsx index 132e57af4..7f85e6f43 100644 --- a/src/views/login/components/account-card.tsx +++ b/src/views/login/components/account-card.tsx @@ -2,10 +2,12 @@ import { CloseIcon } from "@chakra-ui/icons"; import { Box, IconButton, Text } from "@chakra-ui/react"; import { getUserDisplayName } from "../../../helpers/user-metadata"; import { useUserMetadata } from "../../../hooks/use-user-metadata"; -import accountService from "../../../services/account"; +import accountService, { Account } from "../../../services/account"; import { UserAvatar } from "../../../components/user-avatar"; +import AccountInfoBadge from "../../../components/account-info-badge"; -export default function AccountCard({ pubkey }: { pubkey: string }) { +export default function AccountCard({ account }: { account: Account }) { + const pubkey = account.pubkey; // this wont load unless the data is cached since there are no relay connections yet const metadata = useUserMetadata(pubkey, []); @@ -21,10 +23,13 @@ export default function AccountCard({ pubkey }: { pubkey: string }) { cursor="pointer" onClick={() => accountService.switchAccount(pubkey)} > - - - {getUserDisplayName(metadata, pubkey)} - + + + + {getUserDisplayName(metadata, pubkey)} + + + } aria-label="Remove Account" @@ -32,7 +37,7 @@ export default function AccountCard({ pubkey }: { pubkey: string }) { e.stopPropagation(); accountService.removeAccount(pubkey); }} - size="sm" + size="md" variant="ghost" /> diff --git a/src/views/login/start.tsx b/src/views/login/start.tsx index 5426d193b..77a49b28a 100644 --- a/src/views/login/start.tsx +++ b/src/views/login/start.tsx @@ -86,7 +86,7 @@ export default function LoginStartView() { {accounts.map((account) => ( - + ))} diff --git a/src/views/user/about.tsx b/src/views/user/about.tsx index c86abd5c7..8b3926d86 100644 --- a/src/views/user/about.tsx +++ b/src/views/user/about.tsx @@ -8,6 +8,7 @@ import { AccordionItem, AccordionPanel, Box, + Button, Flex, Heading, IconButton, @@ -40,6 +41,7 @@ import { readablizeSats } from "../../helpers/bolt11"; import { UserAvatar } from "../../components/user-avatar"; import { useIsMobile } from "../../hooks/use-is-mobile"; import { getUserDisplayName } from "../../helpers/user-metadata"; +import { useSharableProfileId } from "../../hooks/use-shareable-profile-id"; function buildDescriptionContent(description: string) { let content: EmbedableContent = [description.trim()]; @@ -59,12 +61,10 @@ export default function UserAboutTab() { const metadata = useUserMetadata(pubkey, contextRelays); const contacts = useUserContacts(pubkey, contextRelays); const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey); + const nprofile = useSharableProfileId(pubkey); const { value: stats } = useAsync(() => userTrustedStatsService.getUserStats(pubkey), [pubkey]); - const account = useCurrentAccount(); - const isSelf = pubkey === account?.pubkey; - const aboutContent = metadata?.about && buildDescriptionContent(metadata?.about); return ( @@ -259,6 +259,17 @@ export default function UserAboutTab() { )} + + + ); } 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..3945a7ea8 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 GenericNoteTimeline from "../../components/generic-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 ( 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"