mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-18 11:32:30 +02:00
Merge branch 'next'
This commit is contained in:
5
.changeset/grumpy-apes-tell.md
Normal file
5
.changeset/grumpy-apes-tell.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
show type of account on account picker
|
5
.changeset/sharp-sheep-yawn.md
Normal file
5
.changeset/sharp-sheep-yawn.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add views for watching streams
|
5
.changeset/silent-buckets-joke.md
Normal file
5
.changeset/silent-buckets-joke.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
truncate open graph card description
|
5
.changeset/soft-lions-cry.md
Normal file
5
.changeset/soft-lions-cry.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
cache timelines
|
@@ -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",
|
||||
|
29
src/app.tsx
29
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 (
|
||||
<Page>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
const RootPage = () => (
|
||||
<Page>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Page>
|
||||
);
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
@@ -61,6 +59,7 @@ const router = createHashRouter([
|
||||
{ path: "nsec", element: <LoginNsecView /> },
|
||||
],
|
||||
},
|
||||
{ path: "streams/:naddr", element: <StreamView /> },
|
||||
{
|
||||
path: "/",
|
||||
element: <RootPage />,
|
||||
@@ -106,6 +105,10 @@ const router = createHashRouter([
|
||||
children: [
|
||||
{ path: "", element: <FollowingTab /> },
|
||||
{ path: "following", element: <FollowingTab /> },
|
||||
{
|
||||
path: "streams",
|
||||
element: <LiveStreamsTab />,
|
||||
},
|
||||
{ path: "global", element: <GlobalTab /> },
|
||||
],
|
||||
},
|
||||
|
@@ -77,8 +77,8 @@ class RelayTimelineLoader {
|
||||
|
||||
export class TimelineLoader {
|
||||
cursor = dayjs().unix();
|
||||
query: NostrQuery;
|
||||
relays: string[];
|
||||
query?: NostrQuery;
|
||||
relays: string[] = [];
|
||||
|
||||
events = new PersistentSubject<NostrEvent[]>([]);
|
||||
timeline = new PersistentSubject<NostrEvent[]>([]);
|
||||
@@ -92,14 +92,9 @@ export class TimelineLoader {
|
||||
|
||||
private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
|
||||
|
||||
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<string>();
|
||||
@@ -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;
|
||||
|
@@ -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");
|
||||
}
|
||||
}
|
27
src/components/account-info-badge.tsx
Normal file
27
src/components/account-info-badge.tsx
Normal file
@@ -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 (
|
||||
<Badge {...props} variant="solid" colorScheme="green">
|
||||
extension
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (account.secKey) {
|
||||
return (
|
||||
<Badge {...props} variant="solid" colorScheme="red">
|
||||
nsec
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (account.readonly) {
|
||||
return (
|
||||
<Badge {...props} variant="solid" colorScheme="blue">
|
||||
read-only
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
@@ -7,12 +7,14 @@ import RawValue from "./raw-value";
|
||||
import RawJson from "./raw-json";
|
||||
import { useSharableProfileId } from "../../hooks/use-shareable-profile-id";
|
||||
import userRelaysService from "../../services/user-relays";
|
||||
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||
|
||||
export default function UserDebugModal({ pubkey, ...props }: { pubkey: string } & Omit<ModalProps, "children">) {
|
||||
const npub = useMemo(() => normalizeToBech32(pubkey, Bech32Prefix.Pubkey), [pubkey]);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const nprofile = useSharableProfileId(pubkey);
|
||||
const relays = userRelaysService.requester.getSubject(pubkey).value;
|
||||
const tipMetadata = useUserLNURLMetadata(pubkey);
|
||||
|
||||
return (
|
||||
<Modal {...props}>
|
||||
@@ -25,6 +27,7 @@ export default function UserDebugModal({ pubkey, ...props }: { pubkey: string }
|
||||
{npub && <RawValue heading="npub" value={npub} />}
|
||||
<RawValue heading="nprofile" value={nprofile} />
|
||||
<RawJson heading="Parsed Metadata (kind 0)" json={metadata} />
|
||||
<RawJson heading="LNURL metadata" json={tipMetadata.metadata} />
|
||||
{relays && <RawJson heading="Relay List (kind 10002)" json={relays} />}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
|
19
src/components/embeded-content.tsx
Normal file
19
src/components/embeded-content.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { EmbedableContent } from "../helpers/embeds";
|
||||
import { Text } from "@chakra-ui/react";
|
||||
|
||||
export default function EmbeddedContent({ content }: { content: EmbedableContent }) {
|
||||
return (
|
||||
<>
|
||||
{content.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<Text as="span" key={"part-" + i}>
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
React.cloneElement(part, { key: "part-" + i })
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
92
src/components/invoice-modal.tsx
Normal file
92
src/components/invoice-modal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons";
|
||||
import QrCodeSvg from "./qr-code-svg";
|
||||
import { CopyIconButton } from "./copy-icon-button";
|
||||
import { useIsMobile } from "../hooks/use-is-mobile";
|
||||
|
||||
export default function InvoiceModal({
|
||||
invoice,
|
||||
onClose,
|
||||
onPaid,
|
||||
...props
|
||||
}: Omit<ModalProps, "children"> & { invoice: string; onPaid: () => void }) {
|
||||
const isMobile = useIsMobile();
|
||||
const toast = useToast();
|
||||
const showQr = useDisclosure();
|
||||
|
||||
const payWithWebLn = async (invoice: string) => {
|
||||
if (window.webln && invoice) {
|
||||
if (!window.webln.enabled) await window.webln.enable();
|
||||
await window.webln.sendPayment(invoice);
|
||||
|
||||
if (onPaid) onPaid();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const payWithApp = async (invoice: string) => {
|
||||
window.open("lightning:" + invoice);
|
||||
|
||||
const listener = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
if (onPaid) onPaid();
|
||||
onClose();
|
||||
document.removeEventListener("visibilitychange", listener);
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener("visibilitychange", listener);
|
||||
}, 1000 * 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalBody padding="4">
|
||||
<Flex gap="4" direction="column">
|
||||
{showQr.isOpen && <QrCodeSvg content={invoice} />}
|
||||
<Flex gap="2">
|
||||
<Input value={invoice} readOnly />
|
||||
<IconButton
|
||||
icon={<QrCodeIcon />}
|
||||
aria-label="Show QrCode"
|
||||
onClick={showQr.onToggle}
|
||||
variant="solid"
|
||||
size="md"
|
||||
/>
|
||||
<CopyIconButton text={invoice} aria-label="Copy Invoice" variant="solid" size="md" />
|
||||
</Flex>
|
||||
<Flex gap="2">
|
||||
{window.webln && (
|
||||
<Button onClick={() => payWithWebLn(invoice)} flex={1} variant="solid" size="md">
|
||||
Pay with WebLN
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
onClick={() => payWithApp(invoice)}
|
||||
flex={1}
|
||||
variant="solid"
|
||||
size="md"
|
||||
>
|
||||
Open App
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
65
src/components/live-video-player.tsx
Normal file
65
src/components/live-video-player.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Badge, Flex, FlexProps } from "@chakra-ui/react";
|
||||
import Hls from "hls.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export enum VideoStatus {
|
||||
Online = "online",
|
||||
Offline = "offline",
|
||||
}
|
||||
|
||||
// copied from zap.stream
|
||||
export function LiveVideoPlayer({
|
||||
stream,
|
||||
autoPlay,
|
||||
poster,
|
||||
...props
|
||||
}: FlexProps & { stream?: string; autoPlay?: boolean; poster?: string }) {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const [status, setStatus] = useState<VideoStatus>();
|
||||
|
||||
useEffect(() => {
|
||||
if (stream && video.current && !video.current.src && Hls.isSupported()) {
|
||||
try {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(stream);
|
||||
hls.attachMedia(video.current);
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
const errorType = data.type;
|
||||
if (errorType === Hls.ErrorTypes.NETWORK_ERROR && data.fatal) {
|
||||
hls.stopLoad();
|
||||
hls.detachMedia();
|
||||
setStatus(VideoStatus.Offline);
|
||||
}
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setStatus(VideoStatus.Online);
|
||||
});
|
||||
return () => hls.destroy();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus(VideoStatus.Offline);
|
||||
}
|
||||
}
|
||||
}, [video, stream]);
|
||||
|
||||
return (
|
||||
<Flex justifyContent="center" alignItems="center" {...props} position="relative">
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="4"
|
||||
left="4"
|
||||
fontSize="1.2rem"
|
||||
colorScheme={status === VideoStatus.Offline ? "red" : undefined}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
<video
|
||||
ref={video}
|
||||
controls={status === VideoStatus.Online}
|
||||
autoPlay={autoPlay}
|
||||
poster={poster}
|
||||
style={{ maxHeight: "100%", maxWidth: "100%", width: "100%" }}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Box, Text } from "@chakra-ui/react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import styled from "@emotion/styled";
|
||||
import { useExpand } from "./expanded";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { ImageGalleryProvider } from "../image-gallery";
|
||||
import { useTrusted } from "./trust";
|
||||
import { renderRedditUrl } from "../embed-types/reddit";
|
||||
import EmbeddedContent from "../embeded-content";
|
||||
|
||||
function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) {
|
||||
let content: EmbedableContent = [event.content.trim()];
|
||||
@@ -99,15 +100,7 @@ export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps)
|
||||
px="2"
|
||||
>
|
||||
<div ref={ref}>
|
||||
{content.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<Text as="span" key={"part-" + i}>
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
React.cloneElement(part, { key: "part-" + i })
|
||||
)
|
||||
)}
|
||||
<EmbeddedContent content={content} />
|
||||
</div>
|
||||
{showOverlay && <GradientOverlay onClick={expand?.onExpand} />}
|
||||
</Box>
|
||||
|
@@ -24,7 +24,7 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
|
||||
{data.ogTitle ?? data.dcTitle}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>{data.ogDescription || data.dcDescription}</Text>
|
||||
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
|
||||
{link}
|
||||
</Box>
|
||||
</LinkBox>
|
||||
|
@@ -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 (
|
||||
<Box display="flex" gap="4" alignItems="center" cursor="pointer" onClick={handleClick}>
|
||||
<UserAvatar pubkey={pubkey} size="sm" />
|
||||
<Text flex={1} mr="4" overflow="hidden">
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Text>
|
||||
<Box flex={1}>
|
||||
<Text isTruncated>{getUserDisplayName(metadata, pubkey)}</Text>
|
||||
<AccountInfoBadge fontSize="0.7em" account={account} />
|
||||
</Box>
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
aria-label="Remove Account"
|
||||
@@ -60,7 +63,7 @@ export function AccountSwitcherList() {
|
||||
return (
|
||||
<Flex gap="2" direction="column" padding="2">
|
||||
{otherAccounts.map((account) => (
|
||||
<AccountItem key={account.pubkey} pubkey={account.pubkey} />
|
||||
<AccountItem key={account.pubkey} account={account} />
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
|
@@ -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";
|
||||
|
@@ -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);
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
76
src/helpers/nostr/stream.ts
Normal file
76
src/helpers/nostr/stream.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import dayjs from "dayjs";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import { unique } from "../array";
|
||||
|
||||
export type ParsedStream = {
|
||||
event: NostrEvent;
|
||||
author: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
image?: string;
|
||||
updated: number;
|
||||
status: "live" | "ended" | string;
|
||||
starts?: number;
|
||||
ends?: number;
|
||||
identifier: string;
|
||||
tags: string[];
|
||||
streaming: string;
|
||||
};
|
||||
|
||||
export function parseStreamEvent(stream: NostrEvent): ParsedStream {
|
||||
const title = stream.tags.find((t) => t[0] === "title")?.[1];
|
||||
const summary = stream.tags.find((t) => t[0] === "summary")?.[1];
|
||||
const image = stream.tags.find((t) => t[0] === "image")?.[1];
|
||||
const starts = stream.tags.find((t) => t[0] === "starts")?.[1];
|
||||
const endsTag = stream.tags.find((t) => t[0] === "ends")?.[1];
|
||||
const streaming = stream.tags.find((t) => t[0] === "streaming")?.[1];
|
||||
const identifier = stream.tags.find((t) => t[0] === "d")?.[1];
|
||||
|
||||
const startTime = starts ? parseInt(starts) : stream.created_at;
|
||||
const endTime = endsTag ? parseInt(endsTag) : dayjs(startTime).add(4, "hour").unix();
|
||||
|
||||
if (!title) throw new Error("missing title");
|
||||
if (!identifier) throw new Error("missing identifier");
|
||||
if (!streaming) throw new Error("missing streaming");
|
||||
|
||||
let status = stream.tags.find((t) => t[0] === "status")?.[1] || "ended";
|
||||
if (endTime > dayjs().unix()) {
|
||||
status = "ended";
|
||||
}
|
||||
// if the stream has not been updated in a day consider it ended
|
||||
if (stream.created_at < dayjs().subtract(1, "day").unix()) {
|
||||
status = "ended";
|
||||
}
|
||||
|
||||
const tags = unique(stream.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1] as string));
|
||||
|
||||
return {
|
||||
author: stream.pubkey,
|
||||
event: stream,
|
||||
updated: stream.created_at,
|
||||
streaming,
|
||||
tags,
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
status,
|
||||
starts: startTime,
|
||||
ends: endTime,
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
|
||||
export function getATag(stream: ParsedStream) {
|
||||
return `${stream.event.kind}:${stream.author}:${stream.starts}`;
|
||||
}
|
||||
|
||||
export function buildChatMessage(stream: ParsedStream, content: string) {
|
||||
const template: DraftNostrEvent = {
|
||||
tags: [["a", getATag(stream)]],
|
||||
content,
|
||||
created_at: dayjs().unix(),
|
||||
kind: 1311,
|
||||
};
|
||||
|
||||
return template;
|
||||
}
|
@@ -87,4 +87,22 @@ function cachedParseZapEvent(event: NostrEvent) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function requestZapInvoice(zapRequest: NostrEvent, lnurl: string) {
|
||||
const amount = zapRequest.tags.find((t) => t[0] === "amount")?.[1];
|
||||
if (!amount) throw new Error("missing amount");
|
||||
|
||||
const callbackUrl = new URL(lnurl);
|
||||
callbackUrl.searchParams.append("amount", amount);
|
||||
callbackUrl.searchParams.append("nostr", JSON.stringify(zapRequest));
|
||||
|
||||
const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json());
|
||||
|
||||
if (payRequest as string) {
|
||||
const parsed = parsePaymentRequest(payRequest);
|
||||
if (parsed.amount !== parseInt(amount)) throw new Error("incorrect amount");
|
||||
|
||||
return payRequest as string;
|
||||
} else throw new Error("Failed to get invoice");
|
||||
}
|
||||
|
||||
export { cachedParseZapEvent as parseZapEvent };
|
||||
|
@@ -4,13 +4,13 @@ import { RelayMode } from "../classes/relay";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useUserRelays } from "./use-user-relays";
|
||||
|
||||
export function useSharableProfileId(pubkey: string) {
|
||||
export function useSharableProfileId(pubkey: string, relayCount = 2) {
|
||||
const userRelays = useUserRelays(pubkey);
|
||||
|
||||
return useMemo(() => {
|
||||
const writeUrls = userRelays.filter((r) => r.mode & RelayMode.WRITE).map((r) => r.url);
|
||||
const ranked = relayScoreboardService.getRankedRelays(writeUrls);
|
||||
const onlyTwo = ranked.slice(0, 2);
|
||||
const onlyTwo = ranked.slice(0, relayCount);
|
||||
|
||||
return onlyTwo.length > 0 ? nip19.nprofileEncode({ pubkey, relays: onlyTwo }) : nip19.npubEncode(pubkey);
|
||||
}, [userRelays]);
|
||||
|
@@ -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<TimelineLoader | null>(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;
|
||||
}
|
||||
|
14
src/hooks/use-user-lnurl-metadata.ts
Normal file
14
src/hooks/use-user-lnurl-metadata.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useAsync } from "react-use";
|
||||
import { useUserMetadata } from "./use-user-metadata";
|
||||
import lnurlMetadataService from "../services/lnurl-metadata";
|
||||
|
||||
export default function useUserLNURLMetadata(pubkey: string) {
|
||||
const userMetadata = useUserMetadata(pubkey);
|
||||
const address = userMetadata?.lud06 || userMetadata?.lud16;
|
||||
const { value: metadata } = useAsync(
|
||||
async () => (address ? lnurlMetadataService.requestMetadata(address) : undefined),
|
||||
[address]
|
||||
);
|
||||
|
||||
return { metadata, address };
|
||||
}
|
@@ -3,6 +3,7 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
|
||||
import { SigningProvider } from "./signing-provider";
|
||||
import createTheme from "../theme";
|
||||
import useAppSettings from "../hooks/use-app-settings";
|
||||
import { InvoiceModalProvider } from "./invoice-modal";
|
||||
|
||||
export const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { primaryColor } = useAppSettings();
|
||||
@@ -10,7 +11,9 @@ export const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme} colorModeManager={localStorageManager}>
|
||||
<SigningProvider>{children}</SigningProvider>
|
||||
<SigningProvider>
|
||||
<InvoiceModalProvider>{children}</InvoiceModalProvider>
|
||||
</SigningProvider>
|
||||
</ChakraProvider>
|
||||
);
|
||||
};
|
||||
|
52
src/providers/invoice-modal.tsx
Normal file
52
src/providers/invoice-modal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useCallback, useContext, useState } from "react";
|
||||
import InvoiceModal from "../components/invoice-modal";
|
||||
import createDefer, { Deferred } from "../classes/deferred";
|
||||
|
||||
export type InvoiceModalContext = {
|
||||
requestPay: (invoice: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const InvoiceModalContext = React.createContext<InvoiceModalContext>({
|
||||
requestPay: () => {
|
||||
throw new Error("not setup yet");
|
||||
},
|
||||
});
|
||||
|
||||
export function useInvoiceModalContext() {
|
||||
return useContext(InvoiceModalContext);
|
||||
}
|
||||
|
||||
export const InvoiceModalProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [invoice, setInvoice] = useState<string>();
|
||||
const [defer, setDefer] = useState<Deferred<void>>();
|
||||
|
||||
const requestPay = useCallback((invoice: string) => {
|
||||
const defer = createDefer<void>();
|
||||
setDefer(defer);
|
||||
setInvoice(invoice);
|
||||
return defer;
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (defer) {
|
||||
setInvoice(undefined);
|
||||
setDefer(undefined);
|
||||
defer.reject();
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
const handlePaid = useCallback(() => {
|
||||
if (defer) {
|
||||
setInvoice(undefined);
|
||||
setDefer(undefined);
|
||||
defer.resolve();
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
return (
|
||||
<InvoiceModalContext.Provider value={{ requestPay }}>
|
||||
{children}
|
||||
{invoice && <InvoiceModal isOpen onClose={handleClose} invoice={invoice} onPaid={handlePaid} />}
|
||||
</InvoiceModalContext.Provider>
|
||||
);
|
||||
};
|
32
src/services/timeline-cache.ts
Normal file
32
src/services/timeline-cache.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TimelineLoader } from "../classes/timeline-loader";
|
||||
|
||||
const MAX_CACHE = 4;
|
||||
|
||||
class TimelineCacheService {
|
||||
private timelines = new Map<string, TimelineLoader>();
|
||||
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;
|
@@ -1,33 +0,0 @@
|
||||
import UserTimeline from "../classes/user-timeline";
|
||||
|
||||
const MAX_CACHE = 4;
|
||||
|
||||
class UserTimelineService {
|
||||
timelines = new Map<string, UserTimeline>();
|
||||
|
||||
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;
|
@@ -11,6 +11,7 @@ export type NostrQuery = {
|
||||
authors?: string[];
|
||||
kinds?: number[];
|
||||
"#e"?: string[];
|
||||
"#a"?: string[];
|
||||
"#p"?: string[];
|
||||
"#d"?: string[];
|
||||
"#t"?: string[];
|
||||
|
@@ -26,7 +26,7 @@ import { NostrEvent } from "../../types/nostr-event";
|
||||
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import GenericNoteTimeline from "../../components/generric-note-timeline";
|
||||
import GenericNoteTimeline from "../../components/generic-note-timeline";
|
||||
import { unique } from "../../helpers/array";
|
||||
|
||||
function EditableControls() {
|
||||
|
@@ -13,7 +13,7 @@ import { NostrEvent } from "../../types/nostr-event";
|
||||
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import GenericNoteTimeline from "../../components/generric-note-timeline";
|
||||
import GenericNoteTimeline from "../../components/generic-note-timeline";
|
||||
|
||||
function FollowingTabBody() {
|
||||
const account = useCurrentAccount()!;
|
||||
|
@@ -10,7 +10,7 @@ import { NostrEvent } from "../../types/nostr-event";
|
||||
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import GenericNoteTimeline from "../../components/generric-note-timeline";
|
||||
import GenericNoteTimeline from "../../components/generic-note-timeline";
|
||||
|
||||
export default function GlobalTab() {
|
||||
useAppTitle("global");
|
||||
@@ -33,9 +33,8 @@ export default function GlobalTab() {
|
||||
},
|
||||
[showReplies]
|
||||
);
|
||||
|
||||
const timeline = useTimelineLoader(
|
||||
[`global`, ...selectedRelay].join(","),
|
||||
[`global`, selectedRelay].join(","),
|
||||
selectedRelay ? [selectedRelay] : [],
|
||||
{ kinds: [1] },
|
||||
{ eventFilter }
|
||||
|
@@ -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" },
|
||||
];
|
||||
|
||||
|
56
src/views/home/streams/index.tsx
Normal file
56
src/views/home/streams/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Flex, Select } from "@chakra-ui/react";
|
||||
import { useTimelineLoader } from "../../../hooks/use-timeline-loader";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import IntersectionObserverProvider from "../../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
import StreamCard from "./stream-card";
|
||||
import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
|
||||
export default function LiveStreamsTab() {
|
||||
const readRelays = useReadRelayUrls();
|
||||
const [filterStatus, setFilterStatus] = useState<string>("live");
|
||||
|
||||
const eventFilter = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
try {
|
||||
const parsed = parseStreamEvent(event);
|
||||
return parsed.status === filterStatus;
|
||||
} catch (e) {}
|
||||
return false;
|
||||
},
|
||||
[filterStatus]
|
||||
);
|
||||
const timeline = useTimelineLoader(`streams`, readRelays, { kinds: [30311] }, { eventFilter });
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
const events = useSubject(timeline.timeline);
|
||||
const streams = useMemo(() => {
|
||||
const parsed: ParsedStream[] = [];
|
||||
for (const event of events) {
|
||||
try {
|
||||
parsed.push(parseStreamEvent(event));
|
||||
} catch (e) {}
|
||||
}
|
||||
return parsed.sort((a, b) => b.updated - a.updated);
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<Flex p="2" gap="2" overflow="hidden" direction="column">
|
||||
<Select maxW="sm" value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
|
||||
<option value="live">Live</option>
|
||||
<option value="ended">Ended</option>
|
||||
</Select>
|
||||
<IntersectionObserverProvider callback={callback} root={scrollBox}>
|
||||
<Flex gap="2" wrap="wrap" overflowY="auto" overflowX="hidden" ref={scrollBox}>
|
||||
{streams.map((stream) => (
|
||||
<StreamCard key={stream.event.id} stream={stream} w="sm" />
|
||||
))}
|
||||
</Flex>
|
||||
</IntersectionObserverProvider>
|
||||
</Flex>
|
||||
);
|
||||
}
|
12
src/views/home/streams/status-badge.tsx
Normal file
12
src/views/home/streams/status-badge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Badge } from "@chakra-ui/react";
|
||||
import { ParsedStream } from "../../../helpers/nostr/stream";
|
||||
|
||||
export default function StreamStatusBadge({ stream }: { stream: ParsedStream }) {
|
||||
switch (stream.status) {
|
||||
case "live":
|
||||
return <Badge colorScheme="green">live</Badge>;
|
||||
case "ended":
|
||||
return <Badge colorScheme="red">ended</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
115
src/views/home/streams/stream-card.tsx
Normal file
115
src/views/home/streams/stream-card.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from "react";
|
||||
import { ParsedStream } from "../../../helpers/nostr/stream";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardProps,
|
||||
Divider,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Image,
|
||||
Link,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Spacer,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import dayjs from "dayjs";
|
||||
import relayScoreboardService from "../../../services/relay-scoreboard";
|
||||
import { getEventRelays } from "../../../services/event-relays";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
import StreamStatusBadge from "./status-badge";
|
||||
import { CodeIcon } from "../../../components/icons";
|
||||
import RawValue from "../../../components/debug-modals/raw-value";
|
||||
import RawJson from "../../../components/debug-modals/raw-json";
|
||||
|
||||
export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) {
|
||||
const { title, summary, starts, identifier, status, image } = stream;
|
||||
const devModal = useDisclosure();
|
||||
|
||||
const naddr = useMemo(() => {
|
||||
const relays = getEventRelays(stream.event.id).value;
|
||||
const ranked = relayScoreboardService.getRankedRelays(relays);
|
||||
const onlyTwo = ranked.slice(0, 2);
|
||||
return nip19.naddrEncode({
|
||||
identifier,
|
||||
relays: onlyTwo,
|
||||
pubkey: stream.author,
|
||||
kind: stream.event.kind,
|
||||
});
|
||||
}, [identifier]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...props}>
|
||||
<LinkBox as={CardBody} p="2" display="flex" flexDirection="column" gap="2">
|
||||
{image && <Image src={image} alt={title} borderRadius="lg" />}
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatar pubkey={stream.author} size="sm" />
|
||||
<Heading size="sm">
|
||||
<UserLink pubkey={stream.author} />
|
||||
</Heading>
|
||||
</Flex>
|
||||
<Heading size="md">
|
||||
<LinkOverlay as={RouterLink} to={`/streams/${naddr}`}>
|
||||
{title}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>{summary}</Text>
|
||||
{stream.tags.length > 0 && (
|
||||
<Flex gap="2" wrap="wrap">
|
||||
{stream.tags.map((tag) => (
|
||||
<Badge key={tag}>{tag}</Badge>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
<Text>Updated: {dayjs.unix(stream.updated).fromNow()}</Text>
|
||||
</LinkBox>
|
||||
<Divider />
|
||||
<CardFooter p="2" display="flex" gap="2" alignItems="center">
|
||||
<StreamStatusBadge stream={stream} />
|
||||
<Spacer />
|
||||
<IconButton
|
||||
icon={<CodeIcon />}
|
||||
aria-label="show raw event"
|
||||
onClick={devModal.onOpen}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={devModal.isOpen} onClose={devModal.onClose} size="6xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Raw event</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflow="auto" p="4">
|
||||
<Flex gap="2" direction="column">
|
||||
<RawValue heading="Event Id" value={stream.event.id} />
|
||||
<RawValue heading="naddr" value={naddr} />
|
||||
<RawJson heading="Parsed" json={{ ...stream, event: "Omitted, see JSON below" }} />
|
||||
<RawJson heading="JSON" json={stream.event} />
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
39
src/views/home/streams/stream-summary-content.tsx
Normal file
39
src/views/home/streams/stream-summary-content.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
import { ParsedStream } from "../../../helpers/nostr/stream";
|
||||
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||
import {
|
||||
embedEmoji,
|
||||
embedNostrHashtags,
|
||||
embedNostrLinks,
|
||||
embedNostrMentions,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
} from "../../../components/embed-types";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import EmbeddedContent from "../../../components/embeded-content";
|
||||
|
||||
export default function StreamSummaryContent({ stream, ...props }: BoxProps & { stream: ParsedStream }) {
|
||||
const content = useMemo(() => {
|
||||
if (!stream.summary) return null;
|
||||
let c: EmbedableContent = [stream.summary];
|
||||
|
||||
// general
|
||||
c = embedUrls(c, [renderImageUrl, renderGenericUrl]);
|
||||
|
||||
// nostr
|
||||
c = embedNostrLinks(c);
|
||||
c = embedNostrMentions(c, stream.event);
|
||||
c = embedNostrHashtags(c, stream.event);
|
||||
c = embedEmoji(c, stream.event);
|
||||
|
||||
return c;
|
||||
}, [stream.summary]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
<EmbeddedContent content={content} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
82
src/views/home/streams/stream/index.tsx
Normal file
82
src/views/home/streams/stream/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Button, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useParams, Navigate } from "react-router-dom";
|
||||
import { ParsedStream, parseStreamEvent } from "../../../../helpers/nostr/stream";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { NostrRequest } from "../../../../classes/nostr-request";
|
||||
import { useReadRelayUrls } from "../../../../hooks/use-client-relays";
|
||||
import { unique } from "../../../../helpers/array";
|
||||
import { LiveVideoPlayer } from "../../../../components/live-video-player";
|
||||
import StreamChat from "./stream-chat";
|
||||
import { UserAvatarLink } from "../../../../components/user-avatar-link";
|
||||
import { UserLink } from "../../../../components/user-link";
|
||||
import { useIsMobile } from "../../../../hooks/use-is-mobile";
|
||||
import { AdditionalRelayProvider } from "../../../../providers/additional-relay-context";
|
||||
import StreamSummaryContent from "../stream-summary-content";
|
||||
|
||||
function StreamPage({ stream }: { stream: ParsedStream }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
h="full"
|
||||
overflowX="hidden"
|
||||
overflowY="auto"
|
||||
direction={isMobile ? "column" : "row"}
|
||||
p={isMobile ? 0 : "2"}
|
||||
gap={isMobile ? 0 : "4"}
|
||||
>
|
||||
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}>
|
||||
<LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" />
|
||||
<Flex gap={isMobile ? "2" : "4"} alignItems="center" p={isMobile ? "2" : 0}>
|
||||
<UserAvatarLink pubkey={stream.author} />
|
||||
<Box>
|
||||
<Heading size="md">
|
||||
<UserLink pubkey={stream.author} />
|
||||
</Heading>
|
||||
<Text>{stream.title}</Text>
|
||||
</Box>
|
||||
<Spacer />
|
||||
<Button as={RouterLink} to="/streams">
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} />
|
||||
</Flex>
|
||||
<StreamChat stream={stream} flexGrow={1} maxW={isMobile ? undefined : "lg"} maxH="100vh" flexShrink={0} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StreamView() {
|
||||
const { naddr } = useParams();
|
||||
if (!naddr) return <Navigate replace to="/streams" />;
|
||||
|
||||
const readRelays = useReadRelayUrls();
|
||||
const [stream, setStream] = useState<ParsedStream>();
|
||||
const [relays, setRelays] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = nip19.decode(naddr);
|
||||
if (parsed.type !== "naddr") throw new Error("Invalid stream address");
|
||||
if (parsed.data.kind !== 30311) throw new Error("Invalid stream kind");
|
||||
|
||||
const request = new NostrRequest(unique([...readRelays, ...(parsed.data.relays ?? [])]));
|
||||
request.onEvent.subscribe((event) => {
|
||||
setStream(parseStreamEvent(event));
|
||||
if (parsed.data.relays) setRelays(parsed.data.relays);
|
||||
});
|
||||
request.start({ kinds: [parsed.data.kind], "#d": [parsed.data.identifier], authors: [parsed.data.pubkey] });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}, [naddr]);
|
||||
|
||||
if (!stream) return <Spinner />;
|
||||
return (
|
||||
<AdditionalRelayProvider relays={relays}>
|
||||
<StreamPage stream={stream} />
|
||||
</AdditionalRelayProvider>
|
||||
);
|
||||
}
|
216
src/views/home/streams/stream/stream-chat.tsx
Normal file
216
src/views/home/streams/stream/stream-chat.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardProps,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Input,
|
||||
Spacer,
|
||||
Text,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { ParsedStream, buildChatMessage, getATag } from "../../../../helpers/nostr/stream";
|
||||
import { useTimelineLoader } from "../../../../hooks/use-timeline-loader";
|
||||
import { useReadRelayUrls } from "../../../../hooks/use-client-relays";
|
||||
import { useAdditionalRelayContext } from "../../../../providers/additional-relay-context";
|
||||
import useSubject from "../../../../hooks/use-subject";
|
||||
import { truncatedId } from "../../../../helpers/nostr-event";
|
||||
import { UserAvatar } from "../../../../components/user-avatar";
|
||||
import { UserLink } from "../../../../components/user-link";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../../../types/nostr-event";
|
||||
import IntersectionObserverProvider, {
|
||||
useRegisterIntersectionEntity,
|
||||
} from "../../../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import { embedUrls } from "../../../../helpers/embeds";
|
||||
import { embedEmoji, renderGenericUrl, renderImageUrl } from "../../../../components/embed-types";
|
||||
import EmbeddedContent from "../../../../components/embeded-content";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useSigningContext } from "../../../../providers/signing-provider";
|
||||
import { nostrPostAction } from "../../../../classes/nostr-post-action";
|
||||
import { useUserRelays } from "../../../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../../../classes/relay";
|
||||
import { unique } from "../../../../helpers/array";
|
||||
import { LightningIcon } from "../../../../components/icons";
|
||||
import { parseZapEvent, requestZapInvoice } from "../../../../helpers/zaps";
|
||||
import { readablizeSats } from "../../../../helpers/bolt11";
|
||||
import { Kind } from "nostr-tools";
|
||||
import useUserLNURLMetadata from "../../../../hooks/use-user-lnurl-metadata";
|
||||
import { useInvoiceModalContext } from "../../../../providers/invoice-modal";
|
||||
|
||||
function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
const content = useMemo(() => {
|
||||
let c = embedUrls([event.content], [renderImageUrl, renderGenericUrl]);
|
||||
c = embedEmoji(c, event);
|
||||
return c;
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" ref={ref}>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatar pubkey={event.pubkey} size="xs" />
|
||||
<UserLink
|
||||
pubkey={event.pubkey}
|
||||
fontWeight="bold"
|
||||
color={event.pubkey === stream.author ? "rgb(248, 56, 217)" : "cyan"}
|
||||
/>
|
||||
<Spacer />
|
||||
<Text>{dayjs.unix(event.created_at).fromNow()}</Text>
|
||||
</Flex>
|
||||
<Box>
|
||||
<EmbeddedContent content={content} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, zap.id);
|
||||
|
||||
const { request, payment } = parseZapEvent(zap);
|
||||
const content = useMemo(() => {
|
||||
let c = embedUrls([request.content], [renderImageUrl, renderGenericUrl]);
|
||||
c = embedEmoji(c, request);
|
||||
return c;
|
||||
}, [request.content]);
|
||||
|
||||
if (!payment.amount) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" borderRadius="md" borderColor="yellow.400" borderWidth="1px" p="2" ref={ref}>
|
||||
<Flex gap="2">
|
||||
<LightningIcon color="yellow.400" />
|
||||
<UserAvatar pubkey={zap.pubkey} size="xs" />
|
||||
<UserLink pubkey={request.pubkey} fontWeight="bold" color="yellow.400" />
|
||||
<Text>zapped {readablizeSats(payment.amount / 1000)} sats</Text>
|
||||
<Spacer />
|
||||
<Text>{dayjs.unix(request.created_at).fromNow()}</Text>
|
||||
</Flex>
|
||||
<Box>
|
||||
<EmbeddedContent content={content} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StreamChat({ stream, ...props }: CardProps & { stream: ParsedStream }) {
|
||||
const toast = useToast();
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
const readRelays = useReadRelayUrls(contextRelays);
|
||||
const writeRelays = useUserRelays(stream.author)
|
||||
.filter((r) => r.mode & RelayMode.READ)
|
||||
.map((r) => r.url);
|
||||
|
||||
const timeline = useTimelineLoader(`${truncatedId(stream.event.id)}-chat`, readRelays, {
|
||||
"#a": [getATag(stream)],
|
||||
kinds: [1311, 9735],
|
||||
});
|
||||
|
||||
const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
const { requestSignature } = useSigningContext();
|
||||
const { register, handleSubmit, formState, reset, getValues } = useForm({
|
||||
defaultValues: { content: "" },
|
||||
});
|
||||
const sendMessage = handleSubmit(async (values) => {
|
||||
try {
|
||||
const draft = buildChatMessage(stream, values.content);
|
||||
const signed = await requestSignature(draft);
|
||||
if (!signed) throw new Error("Failed to sign");
|
||||
nostrPostAction(unique([...contextRelays, ...writeRelays]), signed);
|
||||
reset();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
const { requestPay } = useInvoiceModalContext();
|
||||
const zapMetadata = useUserLNURLMetadata(stream.author);
|
||||
const zapMessage = useCallback(async () => {
|
||||
try {
|
||||
if (!zapMetadata.metadata?.callback) throw new Error("bad lnurl endpoint");
|
||||
|
||||
const content = getValues().content;
|
||||
const amount = 100;
|
||||
const zapRequest: DraftNostrEvent = {
|
||||
kind: Kind.ZapRequest,
|
||||
created_at: dayjs().unix(),
|
||||
content,
|
||||
tags: [
|
||||
["p", stream.author],
|
||||
["a", getATag(stream)],
|
||||
["relays", ...writeRelays],
|
||||
["amount", String(amount * 1000)],
|
||||
],
|
||||
};
|
||||
|
||||
const signed = await requestSignature(zapRequest);
|
||||
if (!signed) throw new Error("Failed to sign");
|
||||
|
||||
const invoice = await requestZapInvoice(signed, zapMetadata.metadata.callback);
|
||||
await requestPay(invoice);
|
||||
|
||||
reset();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message });
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback} root={scrollBox}>
|
||||
<Card {...props} overflow="hidden">
|
||||
<CardHeader py="3">
|
||||
<Heading size="md">Stream Chat</Heading>
|
||||
</CardHeader>
|
||||
<CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}>
|
||||
<Flex
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
ref={scrollBox}
|
||||
direction="column-reverse"
|
||||
flex={1}
|
||||
px="4"
|
||||
py="2"
|
||||
gap="2"
|
||||
>
|
||||
{events.map((event) =>
|
||||
event.kind === 1311 ? (
|
||||
<ChatMessage key={event.id} event={event} stream={stream} />
|
||||
) : (
|
||||
<ZapMessage key={event.id} zap={event} stream={stream} />
|
||||
)
|
||||
)}
|
||||
</Flex>
|
||||
<Box as="form" borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2" onSubmit={sendMessage}>
|
||||
<Input placeholder="Message" {...register("content", { required: true })} autoComplete="off" />
|
||||
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
|
||||
Send
|
||||
</Button>
|
||||
{zapMetadata.metadata?.allowsNostr && (
|
||||
<IconButton
|
||||
icon={<LightningIcon color="yellow.400" />}
|
||||
aria-label="Zap stream"
|
||||
borderColor="yellow.400"
|
||||
variant="outline"
|
||||
onClick={zapMessage}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</IntersectionObserverProvider>
|
||||
);
|
||||
}
|
@@ -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)}
|
||||
>
|
||||
<UserAvatar pubkey={pubkey} size="sm" noProxy />
|
||||
<Text flex={1} mr="4" overflow="hidden">
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Text>
|
||||
<UserAvatar pubkey={pubkey} size="md" noProxy />
|
||||
<Box flex={1}>
|
||||
<Text isTruncated fontWeight="bold">
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Text>
|
||||
<AccountInfoBadge account={account} />
|
||||
</Box>
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
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"
|
||||
/>
|
||||
</Box>
|
||||
|
@@ -86,7 +86,7 @@ export default function LoginStartView() {
|
||||
</Heading>
|
||||
<Flex gap="2" direction="column" minW={300}>
|
||||
{accounts.map((account) => (
|
||||
<AccountCard key={account.pubkey} pubkey={account.pubkey} />
|
||||
<AccountCard key={account.pubkey} account={account} />
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
|
@@ -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() {
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
<Flex gap="2">
|
||||
<Button
|
||||
as={Link}
|
||||
href={`https://nosta.me/${nprofile}`}
|
||||
leftIcon={<Image src="https://nosta.me/images/favicon-32x32.png" w="1.2em" />}
|
||||
rightIcon={<ExternalLinkIcon />}
|
||||
isExternal
|
||||
>
|
||||
Nosta.me page
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@@ -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 }[] = [];
|
||||
|
||||
|
@@ -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<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user