Merge branch 'next'

This commit is contained in:
hzrd149
2023-07-02 11:39:12 -05:00
45 changed files with 639 additions and 555 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Blur images in stream chat

View File

@@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fixed bug with stream loading wrong chat

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
UX improvements to zap modal

View File

@@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix blured images opening when clicked

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add toggle chat button to mobile stream view

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add user likes tab under profile view

View File

@@ -34,9 +34,10 @@ import UserMediaTab from "./views/user/media";
import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about";
import UserLikesTab from "./views/user/likes";
const LiveStreamsTab = React.lazy(() => import("./views/home/streams"));
const StreamView = React.lazy(() => import("./views/home/streams/stream"));
const LiveStreamsTab = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
const SearchView = React.lazy(() => import("./views/search"));
const RootPage = () => (
@@ -73,6 +74,7 @@ const router = createHashRouter([
{ path: "notes", element: <UserNotesTab /> },
{ path: "media", element: <UserMediaTab /> },
{ path: "zaps", element: <UserZapsTab /> },
{ path: "likes", element: <UserLikesTab /> },
{ path: "followers", element: <UserFollowersTab /> },
{ path: "following", element: <UserFollowingTab /> },
{ path: "relays", element: <UserRelaysTab /> },
@@ -97,6 +99,10 @@ const router = createHashRouter([
{ path: "nip19", element: <Nip19ToolsView /> },
],
},
{
path: "streams",
element: <LiveStreamsTab />,
},
{ path: "l/:link", element: <NostrLinkView /> },
{ path: "t/:hashtag", element: <HashTagView /> },
{
@@ -105,10 +111,6 @@ const router = createHashRouter([
children: [
{ path: "", element: <FollowingTab /> },
{ path: "following", element: <FollowingTab /> },
{
path: "streams",
element: <LiveStreamsTab />,
},
{ path: "global", element: <GlobalTab /> },
],
},

View File

@@ -2,14 +2,27 @@ import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
import appSettings from "../../services/app-settings";
import { ImageGalleryLink } from "../image-gallery";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { useTrusted } from "../note/trust";
import { useTrusted } from "../../providers/trust";
import OpenGraphCard from "../open-graph-card";
const BlurredImage = (props: ImageProps) => {
const { isOpen, onOpen } = useDisclosure();
return (
<Box overflow="hidden">
<Image onClick={onOpen} cursor="pointer" filter={isOpen ? "" : "blur(1.5rem)"} {...props} />
<Image
onClick={
!isOpen
? (e) => {
e.stopPropagation();
e.preventDefault();
onOpen();
}
: undefined
}
cursor="pointer"
filter={isOpen ? "" : "blur(1.5rem)"}
{...props}
/>
</Box>
);
};

View File

@@ -101,7 +101,7 @@ export const LinkItem = createIcon({
export const LightningIcon = createIcon({
displayName: "LightningIcon",
d: "M13 10h7l-9 13v-9H4l9-13z",
defaultProps,
defaultProps: { ...defaultProps, color: "yellow.400" },
});
export const RelayIcon = createIcon({
@@ -259,3 +259,9 @@ export const AtIcon = createIcon({
d: "M20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C13.6418 20 15.1681 19.5054 16.4381 18.6571L17.5476 20.3214C15.9602 21.3818 14.0523 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12V13.5C22 15.433 20.433 17 18.5 17C17.2958 17 16.2336 16.3918 15.6038 15.4659C14.6942 16.4115 13.4158 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C13.1258 7 14.1647 7.37209 15.0005 8H17V13.5C17 14.3284 17.6716 15 18.5 15C19.3284 15 20 14.3284 20 13.5V12ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z",
defaultProps,
});
export const LiveStreamIcon = createIcon({
displayName: "LiveStreamIcon",
d: "M16 4C16.5523 4 17 4.44772 17 5V9.2L22.2133 5.55071C22.4395 5.39235 22.7513 5.44737 22.9096 5.6736C22.9684 5.75764 23 5.85774 23 5.96033V18.0397C23 18.3158 22.7761 18.5397 22.5 18.5397C22.3974 18.5397 22.2973 18.5081 22.2133 18.4493L17 14.8V19C17 19.5523 16.5523 20 16 20H2C1.44772 20 1 19.5523 1 19V5C1 4.44772 1.44772 4 2 4H16ZM15 6H3V18H15V6ZM7.4 8.82867C7.47607 8.82867 7.55057 8.85036 7.61475 8.8912L11.9697 11.6625C12.1561 11.7811 12.211 12.0284 12.0924 12.2148C12.061 12.2641 12.0191 12.306 11.9697 12.3375L7.61475 15.1088C7.42837 15.2274 7.18114 15.1725 7.06254 14.9861C7.02169 14.9219 7 14.8474 7 14.7713V9.22867C7 9.00776 7.17909 8.82867 7.4 8.82867ZM21 8.84131L17 11.641V12.359L21 15.1587V8.84131Z",
defaultProps,
});

View File

@@ -11,10 +11,9 @@ import {
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons";
import { ExternalLinkIcon, 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,
@@ -22,17 +21,19 @@ export default function InvoiceModal({
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);
try {
if (window.webln && invoice) {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
if (onPaid) onPaid();
onClose();
if (onPaid) onPaid();
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const payWithApp = async (invoice: string) => {

View File

@@ -37,9 +37,7 @@ export function RepostButton({ event }: { event: NostrEvent }) {
await nostrPostAction(clientRelaysService.getWriteUrls(), repost);
onClose();
} catch (e) {
if (e instanceof Error) {
toast({ status: "error", description: e.message });
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
};

View File

@@ -9,7 +9,7 @@ import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/app-settings";
import EventVerificationIcon from "../event-verification-icon";
import { TrustProvider } from "./trust";
import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link";
export default function EmbeddedNote({ note }: { note: NostrEvent }) {

View File

@@ -32,7 +32,7 @@ import { RepostButton } from "./buttons/repost-button";
import { QuoteRepostButton } from "./buttons/quote-repost-button";
import { ExternalLinkIcon } from "../icons";
import NoteContentWithWarning from "./note-content-with-warning";
import { TrustProvider } from "./trust";
import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";

View File

@@ -21,11 +21,10 @@ import {
renderOpenGraphUrl,
} from "../embed-types";
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) {
function buildContents(event: NostrEvent | DraftNostrEvent) {
let content: EmbedableContent = [event.content.trim()];
// common
@@ -70,8 +69,7 @@ export type NoteContentsProps = {
};
export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps) => {
const trusted = useTrusted();
const content = buildContents(event, trusted);
const content = buildContents(event);
const expand = useExpand();
const [innerHeight, setInnerHeight] = useState(0);
const ref = useRef<HTMLDivElement | null>(null);

View File

@@ -53,12 +53,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
await results.onComplete;
deleteModal.onClose();
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
} finally {
setDeleting(false);
}

View File

@@ -10,10 +10,12 @@ import eventZapsService from "../../services/event-zaps";
import { NostrEvent } from "../../types/nostr-event";
import { LightningIcon } from "../icons";
import ZapModal from "../zap-modal";
import { useInvoiceModalContext } from "../../providers/invoice-modal";
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const account = useCurrentAccount();
const metadata = useUserMetadata(note.pubkey);
const { requestPay } = useInvoiceModalContext();
const zaps = useEventZaps(note.id) ?? [];
const parsedZaps = useMemo(() => {
const parsed = [];
@@ -29,7 +31,11 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } &
const hasZapped = !!account && parsedZaps.some((zapRequest) => zapRequest.request.pubkey === account.pubkey);
const tipAddress = metadata?.lud06 || metadata?.lud16;
const invoicePaid = () => eventZapsService.requestZaps(note.id, clientRelaysService.getReadUrls(), true);
const handleInvoice = async (invoice: string) => {
onClose();
await requestPay(invoice);
eventZapsService.requestZaps(note.id, clientRelaysService.getReadUrls(), true);
};
return (
<>
@@ -44,7 +50,9 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } &
>
{readablizeSats(totalZaps(zaps) / 1000)}
</Button>
{isOpen && <ZapModal isOpen={isOpen} onClose={onClose} event={note} onPaid={invoicePaid} pubkey={note.pubkey} />}
{isOpen && (
<ZapModal isOpen={isOpen} onClose={onClose} event={note} onInvoice={handleInvoice} pubkey={note.pubkey} />
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSingleEvent from "../../hooks/use-single-event";
import EmbeddedNote from "./embeded-note";
import EmbeddedNote from "./embedded-note";
import { NoteLink } from "../note-link";
const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => {

View File

@@ -7,12 +7,12 @@ import { ConnectedRelays } from "../connected-relays";
import {
ChatIcon,
FeedIcon,
LiveStreamIcon,
LogoutIcon,
NotificationIcon,
ProfileIcon,
RelayIcon,
SearchIcon,
ToolsIcon,
} from "../icons";
import ProfileLink from "./profile-link";
import AccountSwitcher from "./account-switcher";
@@ -42,6 +42,9 @@ export default function DesktopSideNav() {
<Button onClick={() => navigate("/search")} leftIcon={<SearchIcon />}>
Search
</Button>
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
Streams
</Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile
</Button>

View File

@@ -27,7 +27,7 @@ import { ImageIcon } from "../icons";
import { NoteLink } from "../note-link";
import { NoteContents } from "../note/note-contents";
import { PostResults } from "./post-results";
import { TrustProvider } from "../note/trust";
import { TrustProvider } from "../../providers/trust";
function emptyDraft(): DraftNostrEvent {
return {
@@ -96,12 +96,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
setDraft((d) => ({ ...d, content: (d.content += imageUrl) }));
}
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
};

View File

@@ -9,7 +9,7 @@ import { NoteMenu } from "./note/note-menu";
import { UserAvatar } from "./user-avatar";
import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
import { UserLink } from "./user-link";
import { TrustProvider } from "./note/trust";
import { TrustProvider } from "../providers/trust";
import { safeJson } from "../helpers/parse";
import { verifySignature } from "nostr-tools";
import { useReadRelayUrls } from "../hooks/use-client-relays";

View File

@@ -1,13 +1,13 @@
import { IconButton, IconButtonProps, useDisclosure, useToast } from "@chakra-ui/react";
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { LightningIcon } from "./icons";
import { useState } from "react";
import { encodeText } from "../helpers/bech32";
import ZapModal from "./zap-modal";
import { useInvoiceModalContext } from "../providers/invoice-modal";
export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) => {
const metadata = useUserMetadata(pubkey);
const { isOpen, onOpen, onClose } = useDisclosure();
const { requestPay } = useInvoiceModalContext();
if (!metadata) return null;
// use lud06 and lud16 fields interchangeably
@@ -25,7 +25,17 @@ export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<Ic
color="yellow.400"
{...props}
/>
{isOpen && <ZapModal isOpen={isOpen} onClose={onClose} pubkey={pubkey} />}
{isOpen && (
<ZapModal
isOpen={isOpen}
onClose={onClose}
pubkey={pubkey}
onInvoice={async (invoice) => {
await requestPay(invoice);
onClose();
}}
/>
)}
</>
);
};

View File

@@ -1,41 +1,40 @@
import {
Box,
Button,
Flex,
IconButton,
Heading,
Image,
Input,
InputGroup,
InputLeftElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalOverlay,
ModalProps,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useState } from "react";
import { getUserDisplayName } from "../helpers/user-metadata";
import { NostrEvent } from "../types/nostr-event";
import { SubmitHandler, useForm } from "react-hook-form";
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import { useForm } from "react-hook-form";
import { UserAvatar } from "./user-avatar";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { UserLink } from "./user-link";
import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11";
import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons";
import lnurlMetadataService from "../services/lnurl-metadata";
import { useAsync } from "react-use";
import { nip57 } from "nostr-tools";
import { LightningIcon } from "./icons";
import { Kind } from "nostr-tools";
import clientRelaysService from "../services/client-relays";
import { getEventRelays } from "../services/event-relays";
import { useSigningContext } from "../providers/signing-provider";
import QrCodeSvg from "./qr-code-svg";
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";
import { ParsedStream, getATag } from "../helpers/nostr/stream";
import EmbeddedNote from "./note/embedded-note";
import dayjs from "dayjs";
import { unique } from "../helpers/array";
import { useUserRelays } from "../hooks/use-user-relays";
import { RelayMode } from "../classes/relay";
import relayScoreboardService from "../services/relay-scoreboard";
type FormValues = {
amount: number;
@@ -43,29 +42,30 @@ type FormValues = {
};
export type ZapModalProps = Omit<ModalProps, "children"> & {
event?: NostrEvent;
pubkey: string;
onPaid?: () => void;
event?: NostrEvent;
stream?: ParsedStream;
initialComment?: string;
initialAmount?: number;
onInvoice: (invoice: string) => void;
};
export default function ZapModal({
event,
pubkey,
stream,
onClose,
onPaid,
initialComment,
initialAmount,
onInvoice,
...props
}: ZapModalProps) {
const metadata = useUserMetadata(pubkey);
const { requestSignature } = useSigningContext();
const toast = useToast();
const [promptInvoice, setPromptInvoice] = useState<string>();
const { isOpen: showQr, onToggle: toggleQr } = useDisclosure();
const isMobile = useIsMobile();
const { zapAmounts } = useSubject(appSettings);
const { requestSignature } = useSigningContext();
const { customZapAmounts } = useSubject(appSettings);
const userReadRelays = useUserRelays(pubkey)
.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url);
const {
register,
@@ -76,7 +76,7 @@ export default function ZapModal({
} = useForm<FormValues>({
mode: "onBlur",
defaultValues: {
amount: initialAmount ?? zapAmounts[0],
amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100),
comment: initialComment ?? "",
},
});
@@ -86,7 +86,7 @@ export default function ZapModal({
const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey;
const actionName = canZap ? "Zap" : "Tip";
const onSubmitZap: SubmitHandler<FormValues> = async (values) => {
const onSubmitZap = handleSubmit(async (values) => {
try {
if (!tipAddress) throw new Error("No lightning address");
if (lnurlMetadata) {
@@ -95,21 +95,29 @@ export default function ZapModal({
if (amountInMilisat > lnurlMetadata.maxSendable) throw new Error("amount to large");
if (amountInMilisat < lnurlMetadata.minSendable) throw new Error("amount to small");
if (canZap) {
const otherRelays = event ? getEventRelays(event.id).value : [];
const readRelays = clientRelaysService.getReadUrls();
const eventRelays = event ? getEventRelays(event.id).value : [];
const eventRelaysRanked = relayScoreboardService.getRankedRelays(eventRelays).slice(0, 4);
const writeRelays = clientRelaysService.getWriteUrls();
const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4);
const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4);
const zapRequest = nip57.makeZapRequest({
profile: pubkey,
event: event?.id ?? null,
relays: [...otherRelays, ...readRelays],
amount: amountInMilisat,
comment: values.comment,
});
const zapRequest: DraftNostrEvent = {
kind: Kind.ZapRequest,
created_at: dayjs().unix(),
content: values.comment,
tags: [
["p", pubkey],
["relays", ...unique([...writeRelaysRanked, ...userReadRelaysRanked, ...eventRelaysRanked])],
["amount", String(amountInMilisat)],
],
};
if (event) zapRequest.tags.push(["e", event.id]);
if (stream) zapRequest.tags.push(["a", getATag(stream)]);
const signed = await requestSignature(zapRequest);
if (signed) {
const payRequest = await requestZapInvoice(signed, lnurlMetadata.callback);
payInvoice(payRequest);
await onInvoice(payRequest);
}
} else {
const callbackUrl = new URL(lnurlMetadata.callback);
@@ -121,148 +129,83 @@ export default function ZapModal({
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== amountInMilisat) throw new Error("incorrect amount");
payInvoice(payRequest);
await onInvoice(payRequest);
} else throw new Error("Failed to get invoice");
}
} else throw new Error("Failed to get LNURL metadata");
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
};
const payWithWebLn = async (invoice: string) => {
if (window.webln && invoice) {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
toast({
title: actionName + " sent",
status: "success",
duration: 3000,
});
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);
};
const payInvoice = (invoice: string) => {
switch (appSettings.value.lightningPayMode) {
case "webln":
payWithWebLn(invoice);
break;
case "external":
payWithApp(invoice);
break;
default:
case "prompt":
setPromptInvoice(invoice);
break;
}
};
const handleClose = () => {
// if there was an invoice and we are closing the modal. presume it was paid
if (promptInvoice && onPaid) {
onPaid();
}
onClose();
};
});
return (
<Modal onClose={handleClose} {...props}>
<Modal onClose={onClose} size="xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody padding="4">
{promptInvoice ? (
<form onSubmit={onSubmitZap}>
<Flex gap="4" direction="column">
{showQr && <QrCodeSvg content={promptInvoice} />}
<Flex gap="2">
<Input value={promptInvoice} readOnly />
<IconButton
icon={<QrCodeIcon />}
aria-label="Show QrCode"
onClick={toggleQr}
variant="solid"
size="md"
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="md" />
<Box>
<UserLink pubkey={pubkey} fontWeight="bold" />
<Text>{tipAddress}</Text>
</Box>
</Flex>
{stream && (
<Box>
<Heading size="sm" mb="2">
Stream: {stream.title}
</Heading>
{stream.image && <Image src={stream.image} />}
</Box>
)}
{event && <EmbeddedNote note={event} />}
{(canZap || lnurlMetadata?.commentAllowed) && (
<Input
placeholder="Comment"
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
autoComplete="off"
autoFocus={!initialComment}
/>
<CopyIconButton text={promptInvoice} aria-label="Copy Invoice" variant="solid" size="md" />
</Flex>
<Flex gap="2">
{window.webln && (
<Button onClick={() => payWithWebLn(promptInvoice)} flex={1} variant="solid" size="md">
Pay with WebLN
</Button>
)}
<Button
leftIcon={<ExternalLinkIcon />}
onClick={() => payWithApp(promptInvoice)}
flex={1}
variant="solid"
size="md"
>
Open App
</Button>
</Flex>
</Flex>
) : (
<form onSubmit={handleSubmit(onSubmitZap)}>
<Flex gap="4" direction="column">
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<Text>{actionName}</Text>
<UserLink pubkey={pubkey} />
</Flex>
<Flex gap="2" alignItems="center" flexWrap="wrap">
{zapAmounts.map((amount, i) => (
<Button key={amount + i} onClick={() => setValue("amount", amount)} size="sm" variant="outline">
)}
<Flex gap="2" alignItems="center" flexWrap="wrap">
{customZapAmounts
.split(",")
.map((v) => parseInt(v))
.map((amount, i) => (
<Button
key={amount + i}
onClick={() => {
setValue("amount", amount);
}}
leftIcon={<LightningIcon color="yellow.400" />}
variant="solid"
>
{amount}
</Button>
))}
</Flex>
<Flex gap="2">
<InputGroup maxWidth={32}>
{!isMobile && (
<InputLeftElement pointerEvents="none" color="gray.300" fontSize="1.2em">
<LightningIcon fontSize="1rem" color="yellow.400" />
</InputLeftElement>
)}
<Input
type="number"
placeholder="amount"
isInvalid={!!errors.amount}
step={1}
{...register("amount", { valueAsNumber: true, min: 1, required: true })}
/>
</InputGroup>
{(canZap || lnurlMetadata?.commentAllowed) && (
<Input
placeholder="Comment"
{...register("comment", { maxLength: lnurlMetadata?.commentAllowed ?? 150 })}
autoComplete="off"
/>
)}
</Flex>
</Flex>
<Flex gap="2">
<Input
type="number"
placeholder="Custom amount"
isInvalid={!!errors.amount}
step={1}
flex={1}
{...register("amount", { valueAsNumber: true, min: 1 })}
/>
<Button leftIcon={<LightningIcon />} type="submit" isLoading={isSubmitting} variant="solid" size="md">
{actionName} {getUserDisplayName(metadata, pubkey)} {readablizeSats(watch("amount"))} sats
{actionName} {readablizeSats(watch("amount"))} sats
</Button>
</Flex>
</form>
)}
</Flex>
</form>
</ModalBody>
</ModalContent>
</Modal>

View File

@@ -61,7 +61,7 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
}
export function getATag(stream: ParsedStream) {
return `${stream.event.kind}:${stream.author}:${stream.starts}`;
return `${stream.event.kind}:${stream.author}:${stream.identifier}`;
}
export function buildChatMessage(stream: ParsedStream, content: string) {

View File

@@ -13,12 +13,7 @@ export default function useAppSettings() {
try {
return replaceSettings({ ...settings, ...newSettings });
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[settings]

View File

@@ -1,18 +0,0 @@
import { MutableRefObject, useState } from "react";
import { useInterval } from "react-use";
export default function useScrollPosition(ref: MutableRefObject<HTMLDivElement | null>, interval = 1000) {
const [percent, setPercent] = useState(0);
useInterval(() => {
if (!ref.current) return;
const scrollBottom = ref.current.scrollTop + ref.current.getClientRects()[0].height;
if (ref.current.scrollHeight === 0) {
return setPercent(1);
}
const scrollPosition = Math.min(scrollBottom / ref.current.scrollHeight, 1);
setPercent(scrollPosition);
}, interval);
return percent;
}

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useContext, useState } from "react";
import InvoiceModal from "../components/invoice-modal";
import createDefer, { Deferred } from "../classes/deferred";
import appSettings from "../services/app-settings";
export type InvoiceModalContext = {
requestPay: (invoice: string) => Promise<void>;
@@ -20,7 +21,17 @@ export const InvoiceModalProvider = ({ children }: { children: React.ReactNode }
const [invoice, setInvoice] = useState<string>();
const [defer, setDefer] = useState<Deferred<void>>();
const requestPay = useCallback((invoice: string) => {
const requestPay = useCallback(async (invoice: string) => {
if (window.webln && appSettings.value.autoPayWithWebLN) {
try {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
handlePaid();
return;
} catch (e) {}
}
const defer = createDefer<void>();
setDefer(defer);
setInvoice(invoice);

View File

@@ -37,12 +37,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (!current) throw new Error("No account");
return await signingService.requestSignature(draft, current);
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[toast, current]
@@ -53,12 +48,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (!current) throw new Error("No account");
return await signingService.requestDecrypt(data, pubkey, current);
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[toast, current]
@@ -69,12 +59,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (!current) throw new Error("No account");
return await signingService.requestEncrypt(data, pubkey, current);
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[toast, current]

View File

@@ -1,8 +1,8 @@
import React, { PropsWithChildren, useContext } from "react";
import { NostrEvent } from "../../types/nostr-event";
import { useCurrentAccount } from "../../hooks/use-current-account";
import clientFollowingService from "../../services/client-following";
import useSubject from "../../hooks/use-subject";
import { NostrEvent } from "../types/nostr-event";
import { useCurrentAccount } from "../hooks/use-current-account";
import clientFollowingService from "../services/client-following";
import useSubject from "../hooks/use-subject";
const TrustContext = React.createContext<boolean>(false);

View File

@@ -9,12 +9,6 @@ import db from "./db";
const DTAG = "nostrudel-settings";
export enum LightningPayMode {
Prompt = "prompt",
Webln = "webln",
External = "external",
}
export type AppSettings = {
colorMode: ColorMode;
blurImages: boolean;
@@ -22,8 +16,10 @@ export type AppSettings = {
proxyUserMedia: boolean;
showReactions: boolean;
showSignatureVerification: boolean;
lightningPayMode: LightningPayMode;
zapAmounts: number[];
autoPayWithWebLN: boolean;
customZapAmounts: string;
primaryColor: string;
imageProxy: string;
corsProxy: string;
@@ -40,8 +36,10 @@ export const defaultSettings: AppSettings = {
proxyUserMedia: false,
showReactions: true,
showSignatureVerification: false,
lightningPayMode: LightningPayMode.Prompt,
zapAmounts: [50, 200, 500, 1000],
autoPayWithWebLN: true,
customZapAmounts: "50,200,500,1000,2000,5000",
primaryColor: "#8DB600",
imageProxy: "",
corsProxy: "",

View File

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

View File

@@ -1,216 +0,0 @@
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={request.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 = 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 });
}
};
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>
);
}

View File

@@ -4,7 +4,7 @@ import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons";
import { Note } from "../../components/note";
import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { TrustProvider } from "../../components/note/trust";
import { TrustProvider } from "../../providers/trust";
export type ThreadItemProps = {
post: ThreadItemData;

View File

@@ -243,12 +243,7 @@ export const ProfileEditView = () => {
await results.onComplete;
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};

View File

@@ -61,9 +61,7 @@ function RelaysPage() {
}
setRelayInputValue("");
} catch (e) {
if (e instanceof Error) {
toast({ status: "error", description: e.message });
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const savePending = async () => {

View File

@@ -1,4 +1,4 @@
import { Button, Flex, Accordion, Link } from "@chakra-ui/react";
import { Button, Flex, Accordion, Link, useToast } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { GithubIcon, ToolsIcon } from "../../components/icons";
import LightningSettings from "./lightning-settings";
@@ -10,6 +10,7 @@ import useAppSettings from "../../hooks/use-app-settings";
import { FormProvider, useForm } from "react-hook-form";
export default function SettingsView() {
const toast = useToast();
const { updateSettings, ...settings } = useAppSettings();
const form = useForm({
@@ -18,7 +19,12 @@ export default function SettingsView() {
});
const saveSettings = form.handleSubmit(async (values) => {
await updateSettings(values);
try {
await updateSettings(values);
toast({ title: "Settings saved", status: "success" });
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});
return (

View File

@@ -10,13 +10,15 @@ import {
FormHelperText,
Input,
Select,
Switch,
FormErrorMessage,
} from "@chakra-ui/react";
import { LightningIcon } from "../../components/icons";
import { AppSettings } from "../../services/user-app-settings";
import { useFormContext } from "react-hook-form";
export default function LightningSettings() {
const { register } = useFormContext<AppSettings>();
const { register, formState } = useFormContext<AppSettings>();
return (
<AccordionItem>
@@ -31,44 +33,35 @@ export default function LightningSettings() {
<AccordionPanel>
<Flex direction="column" gap="4">
<FormControl>
<FormLabel htmlFor="lightningPayMode" mb="0">
Payment mode
</FormLabel>
<Select id="lightningPayMode" {...register("lightningPayMode")}>
<option value="prompt">Prompt</option>
<option value="webln">WebLN</option>
<option value="external">External</option>
</Select>
<Flex alignItems="center">
<FormLabel htmlFor="autoPayWithWebLN" mb="0">
Auto pay with WebLN
</FormLabel>
<Switch id="autoPayWithWebLN" {...register("autoPayWithWebLN")} />
</Flex>
<FormHelperText>
<span>Prompt: Ask every time</span>
<br />
<span>WebLN: Use browser extension</span>
<br />
<span>External: Open an external app using "lightning:" link</span>
<span>Enabled: Attempt to automatically pay with WebLN if its available</span>
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel htmlFor="zap-amounts" mb="0">
<FormLabel htmlFor="customZapAmounts" mb="0">
Zap Amounts
</FormLabel>
<Input
id="zap-amounts"
id="customZapAmounts"
autoComplete="off"
{...register("zapAmounts", {
setValueAs: (value: number[] | string) => {
if (Array.isArray(value)) {
return Array.from(value).join(",");
} else {
return value
.split(",")
.map((v) => parseInt(v))
.filter(Boolean)
.sort((a, b) => a - b);
}
{...register("customZapAmounts", {
validate: (v) => {
if (!/^[\d,]*$/.test(v)) return "Must be a list of comma separated numbers";
return true;
},
})}
/>
{formState.errors.customZapAmounts && (
<FormErrorMessage>{formState.errors.customZapAmounts.message}</FormErrorMessage>
)}
<FormHelperText>
<span>Comma separated list of custom zap amounts</span>
</FormHelperText>

View File

@@ -2,8 +2,6 @@ import { useMemo } from "react";
import { ParsedStream } from "../../../helpers/nostr/stream";
import {
Badge,
Button,
ButtonGroup,
Card,
CardBody,
CardFooter,
@@ -13,7 +11,6 @@ import {
Heading,
IconButton,
Image,
Link,
LinkBox,
LinkOverlay,
Modal,
@@ -33,14 +30,13 @@ 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 { title, identifier, image } = stream;
const devModal = useDisclosure();
const naddr = useMemo(() => {
@@ -71,7 +67,6 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
{title}
</LinkOverlay>
</Heading>
<Text>{summary}</Text>
{stream.tags.length > 0 && (
<Flex gap="2" wrap="wrap">
{stream.tags.map((tag) => (

View File

@@ -1,13 +1,13 @@
import { Flex, Select } from "@chakra-ui/react";
import { useTimelineLoader } from "../../../hooks/use-timeline-loader";
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";
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 "./components/stream-card";
import { ParsedStream, parseStreamEvent } from "../../helpers/nostr/stream";
import { NostrEvent } from "../../types/nostr-event";
export default function LiveStreamsTab() {
const readRelays = useReadRelayUrls();

View File

@@ -1,21 +1,41 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useScroll } from "react-use";
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 { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
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";
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 "../components/stream-summary-content";
import { ArrowDownSIcon, ArrowUpSIcon } from "../../../components/icons";
function StreamPage({ stream }: { stream: ParsedStream }) {
const isMobile = useIsMobile();
const scrollBox = useRef<HTMLDivElement | null>(null);
const scrollState = useScroll(scrollBox);
const action =
scrollState.y < 256 ? (
<Button
size="sm"
onClick={() => scrollBox.current?.scroll(0, scrollBox.current.scrollHeight)}
leftIcon={<ArrowDownSIcon />}
>
View Chat
</Button>
) : (
<Button size="sm" onClick={() => scrollBox.current?.scroll(0, 0)} leftIcon={<ArrowUpSIcon />}>
View Stream
</Button>
);
return (
<Flex
@@ -25,6 +45,7 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
direction={isMobile ? "column" : "row"}
p={isMobile ? 0 : "2"}
gap={isMobile ? 0 : "4"}
ref={scrollBox}
>
<Flex gap={isMobile ? "2" : "4"} direction="column" flexGrow={isMobile ? 0 : 1}>
<LiveVideoPlayer stream={stream.streaming} autoPlay poster={stream.image} maxH="100vh" />
@@ -43,7 +64,14 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
</Flex>
<StreamSummaryContent stream={stream} px={isMobile ? "2" : 0} />
</Flex>
<StreamChat stream={stream} flexGrow={1} maxW={isMobile ? undefined : "lg"} maxH="100vh" flexShrink={0} />
<StreamChat
stream={stream}
flexGrow={1}
maxW={isMobile ? undefined : "lg"}
maxH="100vh"
flexShrink={0}
actions={isMobile && action}
/>
</Flex>
);
}

View File

@@ -0,0 +1,238 @@
import { useMemo, useRef } from "react";
import dayjs from "dayjs";
import {
Box,
Button,
Card,
CardBody,
CardHeader,
CardProps,
Flex,
Heading,
IconButton,
Input,
Spacer,
Text,
useDisclosure,
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 { NostrEvent } from "../../../types/nostr-event";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
import {
embedEmoji,
embedNostrHashtags,
embedNostrLinks,
embedNostrMentions,
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 } from "../../../helpers/zaps";
import { readablizeSats } from "../../../helpers/bolt11";
import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata";
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
import { ImageGalleryProvider } from "../../../components/image-gallery";
import { TrustProvider } from "../../../providers/trust";
import ZapModal from "../../../components/zap-modal";
function ChatMessageContent({ event }: { event: NostrEvent }) {
const content = useMemo(() => {
let c: EmbedableContent = [event.content];
c = embedUrls(c, [renderImageUrl, renderGenericUrl]);
// nostr
c = embedNostrLinks(c);
c = embedNostrMentions(c, event);
c = embedNostrHashtags(c, event);
c = embedEmoji(c, event);
return c;
}, [event.content]);
return <EmbeddedContent content={content} />;
}
function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
return (
<TrustProvider event={event}>
<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>
<ChatMessageContent event={event} />
</Box>
</Flex>
</TrustProvider>
);
}
function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, zap.id);
const { request, payment } = parseZapEvent(zap);
if (!payment.amount) return null;
return (
<TrustProvider event={request}>
<Flex direction="column" borderRadius="md" borderColor="yellow.400" borderWidth="1px" p="2" ref={ref}>
<Flex gap="2">
<LightningIcon color="yellow.400" />
<UserAvatar pubkey={request.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>
<ChatMessageContent event={request} />
</Box>
</Flex>
</TrustProvider>
);
}
export default function StreamChat({
stream,
actions,
...props
}: CardProps & { stream: ParsedStream; actions?: React.ReactNode }) {
const toast = useToast();
const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays);
const userReadRelays = 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, ...userReadRelays]), signed);
reset();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});
const zapModal = useDisclosure();
const { requestPay } = useInvoiceModalContext();
const zapMetadata = useUserLNURLMetadata(stream.author);
return (
<>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<ImageGalleryProvider>
<Card {...props} overflow="hidden">
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
<Heading size="md">Stream Chat</Heading>
{actions}
</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={zapModal.onOpen}
/>
)}
</Box>
</CardBody>
</Card>
</ImageGalleryProvider>
</IntersectionObserverProvider>
{zapModal.isOpen && (
<ZapModal
isOpen
stream={stream}
pubkey={stream.author}
onInvoice={async (invoice) => {
reset();
zapModal.onClose();
await requestPay(invoice);
}}
onClose={zapModal.onClose}
initialComment={getValues().content}
/>
)}
</>
);
}

View File

@@ -45,9 +45,7 @@ function EncodeForm() {
setOutput(nprofile);
} catch (e) {
if (e instanceof Error) {
toast({ description: e.message });
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});
@@ -92,9 +90,7 @@ function DecodeForm() {
try {
setOutput(nip19.decode(values.input));
} catch (e) {
if (e instanceof Error) {
toast({ description: e.message });
}
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});

View File

@@ -46,6 +46,7 @@ const tabs = [
{ label: "Media", path: "media" },
{ label: "Zaps", path: "zaps" },
{ label: "Following", path: "following" },
{ label: "Likes", path: "likes" },
{ label: "Relays", path: "relays" },
{ label: "Reports", path: "reports" },
{ label: "Followers", path: "followers" },

79
src/views/user/likes.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { useRef } from "react";
import { useOutletContext } from "react-router-dom";
import { Box, Flex, SkeletonText, Spacer, Text } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { getReferences, truncatedId } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSingleEvent from "../../hooks/use-single-event";
import { Note } from "../../components/note";
import { TrustProvider } from "../../providers/trust";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { NoteMenu } from "../../components/note/note-menu";
const Like = ({ event }: { event: NostrEvent }) => {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays);
const refs = getReferences(event);
const eventId: string | undefined = refs.events[0];
const { event: note } = useSingleEvent(eventId, readRelays);
var content = <></>;
if (!note) return <SkeletonText />;
if (note.kind === Kind.Text) {
content = (
<>
<Flex gap="2" mb="2">
<UserAvatar pubkey={event.pubkey} size="xs" />
<Text>
<UserLink pubkey={event.pubkey} /> {event.content === "+" ? "liked" : "reacted with " + event.content}
</Text>
<Spacer />
<NoteMenu event={event} aria-label="Note menu" variant="ghost" size="xs" />
</Flex>
<Note key={note.id} event={note} maxHeight={1200} />
</>
);
} else content = <>Unknown note type {note.kind}</>;
return <Box ref={ref}>{content}</Box>;
};
export default function UserLikesTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays);
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-likes`, readRelays, { authors: [pubkey], kinds: [7] });
const lines = useSubject(timeline.timeline);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<TrustProvider trust>
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
{lines.map((event) => (
<Like event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</TrustProvider>
</IntersectionObserverProvider>
);
}