mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
add zap buttons to chat messages
This commit is contained in:
parent
b1d11c40cc
commit
e7df2de1c8
@ -1,35 +1,24 @@
|
||||
import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react";
|
||||
import { useMemo } from "react";
|
||||
import { readablizeSats } from "../../helpers/bolt11";
|
||||
import { parseZapEvent, totalZaps } from "../../helpers/zaps";
|
||||
import { totalZaps } from "../../helpers/zaps";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import useEventZaps from "../../hooks/use-event-zaps";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
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";
|
||||
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||
|
||||
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
|
||||
const account = useCurrentAccount();
|
||||
const metadata = useUserMetadata(note.pubkey);
|
||||
const { metadata } = useUserLNURLMetadata(note.pubkey);
|
||||
const { requestPay } = useInvoiceModalContext();
|
||||
const zaps = useEventZaps(note.id) ?? [];
|
||||
const parsedZaps = useMemo(() => {
|
||||
const parsed = [];
|
||||
for (const zap of zaps) {
|
||||
try {
|
||||
parsed.push(parseZapEvent(zap));
|
||||
} catch (e) {}
|
||||
}
|
||||
return parsed;
|
||||
}, [zaps]);
|
||||
const zaps = useEventZaps(note.id);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const hasZapped = !!account && parsedZaps.some((zapRequest) => zapRequest.request.pubkey === account.pubkey);
|
||||
const tipAddress = metadata?.lud06 || metadata?.lud16;
|
||||
const hasZapped = !!account && zaps.some((zap) => zap.request.pubkey === account.pubkey);
|
||||
|
||||
const handleInvoice = async (invoice: string) => {
|
||||
onClose();
|
||||
@ -46,7 +35,7 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } &
|
||||
colorScheme={hasZapped ? "brand" : undefined}
|
||||
{...props}
|
||||
onClick={onOpen}
|
||||
isDisabled={!tipAddress}
|
||||
isDisabled={!metadata?.allowsNostr}
|
||||
>
|
||||
{readablizeSats(totalZaps(zaps) / 1000)}
|
||||
</Button>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { bech32 } from "@scure/base";
|
||||
import { isETag, isPTag, NostrEvent } from "../types/nostr-event";
|
||||
import { parsePaymentRequest } from "./bolt11";
|
||||
import { ParsedInvoice, parsePaymentRequest } from "./bolt11";
|
||||
|
||||
import { Kind0ParsedContent } from "./user-metadata";
|
||||
import { nip57, utils } from "nostr-tools";
|
||||
@ -41,21 +41,18 @@ export function isProfileZap(event: NostrEvent) {
|
||||
return !isNoteZap(event) && event.tags.some(isPTag);
|
||||
}
|
||||
|
||||
export function totalZaps(events: NostrEvent[]) {
|
||||
let total = 0;
|
||||
for (const event of events) {
|
||||
const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1];
|
||||
try {
|
||||
if (bolt11) {
|
||||
const parsed = parsePaymentRequest(bolt11);
|
||||
if (parsed.amount) total += parsed.amount;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return total;
|
||||
export function totalZaps(zaps: ParsedZap[]) {
|
||||
return zaps.reduce((t, zap) => t + (zap.payment.amount || 0), 0);
|
||||
}
|
||||
|
||||
function parseZapEvent(event: NostrEvent) {
|
||||
export type ParsedZap = {
|
||||
event: NostrEvent;
|
||||
request: NostrEvent;
|
||||
payment: ParsedInvoice;
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
function parseZapEvent(event: NostrEvent): ParsedZap {
|
||||
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
|
||||
if (!zapRequestStr) throw new Error("no description tag");
|
||||
|
||||
@ -65,14 +62,14 @@ function parseZapEvent(event: NostrEvent) {
|
||||
const error = nip57.validateZapRequest(zapRequestStr);
|
||||
if (error) throw new Error(error);
|
||||
|
||||
const zapRequest = JSON.parse(zapRequestStr) as NostrEvent;
|
||||
const request = JSON.parse(zapRequestStr) as NostrEvent;
|
||||
const payment = parsePaymentRequest(bolt11);
|
||||
|
||||
const eventId = zapRequest.tags.find(isETag)?.[1];
|
||||
const eventId = request.tags.find(isETag)?.[1];
|
||||
|
||||
return {
|
||||
zap: event,
|
||||
request: zapRequest,
|
||||
event,
|
||||
request,
|
||||
payment,
|
||||
eventId,
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import eventZapsService from "../services/event-zaps";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
import { parseZapEvent } from "../helpers/zaps";
|
||||
|
||||
export default function useEventZaps(eventId: string, additionalRelays: string[] = [], alwaysFetch = true) {
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
@ -11,5 +12,17 @@ export default function useEventZaps(eventId: string, additionalRelays: string[]
|
||||
[eventId, relays.join("|"), alwaysFetch]
|
||||
);
|
||||
|
||||
return useSubject(subject);
|
||||
const events = useSubject(subject) || [];
|
||||
|
||||
const zaps = useMemo(() => {
|
||||
const parsed = [];
|
||||
for (const zap of events) {
|
||||
try {
|
||||
parsed.push(parseZapEvent(zap));
|
||||
} catch (e) {}
|
||||
}
|
||||
return parsed;
|
||||
}, [events]);
|
||||
|
||||
return zaps;
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
|
||||
const scrollState = useScroll(scrollBox);
|
||||
|
||||
const action =
|
||||
scrollState.y < 256 ? (
|
||||
scrollState.y === 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => scrollBox.current?.scroll(0, scrollBox.current.scrollHeight)}
|
||||
@ -69,6 +69,7 @@ function StreamPage({ stream }: { stream: ParsedStream }) {
|
||||
flexGrow={1}
|
||||
maxW={isMobile ? undefined : "lg"}
|
||||
maxH="100vh"
|
||||
minH={isMobile ? "100vh" : undefined}
|
||||
flexShrink={0}
|
||||
actions={isMobile && action}
|
||||
/>
|
||||
|
@ -0,0 +1,30 @@
|
||||
import { useMemo } from "react";
|
||||
import { EmbedableContent, embedUrls } from "../../../../helpers/embeds";
|
||||
import {
|
||||
embedEmoji,
|
||||
embedNostrHashtags,
|
||||
embedNostrLinks,
|
||||
embedNostrMentions,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
} from "../../../../components/embed-types";
|
||||
import EmbeddedContent from "../../../../components/embeded-content";
|
||||
import { NostrEvent } from "../../../../types/nostr-event";
|
||||
|
||||
export default 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} />;
|
||||
}
|
31
src/views/streams/stream/stream-chat/chat-message.tsx
Normal file
31
src/views/streams/stream/stream-chat/chat-message.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, Text } from "@chakra-ui/react";
|
||||
import { ParsedStream } from "../../../../helpers/nostr/stream";
|
||||
import { UserAvatar } from "../../../../components/user-avatar";
|
||||
import { UserLink } from "../../../../components/user-link";
|
||||
import { NostrEvent } from "../../../../types/nostr-event";
|
||||
import { useRegisterIntersectionEntity } from "../../../../providers/intersection-observer";
|
||||
import { TrustProvider } from "../../../../providers/trust";
|
||||
import ChatMessageContent from "./chat-message-content";
|
||||
import NoteZapButton from "../../../../components/note/note-zap-button";
|
||||
|
||||
export default function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<Box>
|
||||
<NoteZapButton note={event} size="xs" variant="ghost" float="right" ml="2" />
|
||||
<Text ref={ref}>
|
||||
<UserAvatar pubkey={event.pubkey} size="xs" display="inline-block" mr="2" />
|
||||
<Text as="span" fontWeight="bold" color={event.pubkey === stream.author ? "rgb(248, 56, 217)" : "cyan"}>
|
||||
<UserLink pubkey={event.pubkey} />
|
||||
{": "}
|
||||
</Text>
|
||||
<ChatMessageContent event={event} />
|
||||
</Text>
|
||||
</Box>
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@ -11,115 +10,31 @@ import {
|
||||
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 { ParsedStream, buildChatMessage, getATag } from "../../../../helpers/nostr/stream";
|
||||
import { useAdditionalRelayContext } from "../../../../providers/additional-relay-context";
|
||||
import { useReadRelayUrls } from "../../../../hooks/use-client-relays";
|
||||
import { useUserRelays } from "../../../../hooks/use-user-relays";
|
||||
import { RelayMode } from "../../../../classes/relay";
|
||||
import ZapModal from "../../../../components/zap-modal";
|
||||
import { LightningIcon } from "../../../../components/icons";
|
||||
import ChatMessage from "./chat-message";
|
||||
import ZapMessage from "./zap-message";
|
||||
import { ImageGalleryProvider } from "../../../../components/image-gallery";
|
||||
import IntersectionObserverProvider from "../../../../providers/intersection-observer";
|
||||
import useUserLNURLMetadata from "../../../../hooks/use-user-lnurl-metadata";
|
||||
import { useInvoiceModalContext } from "../../../../providers/invoice-modal";
|
||||
import { unique } from "../../../../helpers/array";
|
||||
import { nostrPostAction } from "../../../../classes/nostr-post-action";
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { useSigningContext } from "../../../../providers/signing-provider";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import useSubject from "../../../../hooks/use-subject";
|
||||
import { useTimelineLoader } from "../../../../hooks/use-timeline-loader";
|
||||
import { truncatedId } from "../../../../helpers/nostr-event";
|
||||
|
||||
export default function StreamChat({
|
||||
stream,
|
36
src/views/streams/stream/stream-chat/zap-message.tsx
Normal file
36
src/views/streams/stream/stream-chat/zap-message.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, Flex, Text } from "@chakra-ui/react";
|
||||
import { ParsedStream } from "../../../../helpers/nostr/stream";
|
||||
import { UserAvatar } from "../../../../components/user-avatar";
|
||||
import { UserLink } from "../../../../components/user-link";
|
||||
import { NostrEvent } from "../../../../types/nostr-event";
|
||||
import { useRegisterIntersectionEntity } from "../../../../providers/intersection-observer";
|
||||
import { LightningIcon } from "../../../../components/icons";
|
||||
import { parseZapEvent } from "../../../../helpers/zaps";
|
||||
import { readablizeSats } from "../../../../helpers/bolt11";
|
||||
import { TrustProvider } from "../../../../providers/trust";
|
||||
import ChatMessageContent from "./chat-message-content";
|
||||
|
||||
export default 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>
|
||||
</Flex>
|
||||
<Box>
|
||||
<ChatMessageContent event={request} />
|
||||
</Box>
|
||||
</Flex>
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Box, Button, Flex, Select, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
|
||||
import { LightningIcon } from "../../components/icons";
|
||||
@ -97,7 +97,16 @@ const UserZapsTab = () => {
|
||||
{ eventFilter }
|
||||
);
|
||||
|
||||
const zaps = useSubject(timeline.timeline);
|
||||
const events = useSubject(timeline.timeline);
|
||||
const zaps = useMemo(() => {
|
||||
const parsed = [];
|
||||
for (const zap of events) {
|
||||
try {
|
||||
parsed.push(parseZapEvent(zap));
|
||||
} catch (e) {}
|
||||
}
|
||||
return parsed;
|
||||
}, [events]);
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
@ -111,17 +120,17 @@ const UserZapsTab = () => {
|
||||
<option value="note">Note Zaps</option>
|
||||
<option value="profile">Profile Zaps</option>
|
||||
</Select>
|
||||
{zaps.length && (
|
||||
{events.length && (
|
||||
<Flex gap="2">
|
||||
<LightningIcon color="yellow.400" />
|
||||
<Text>
|
||||
{readablizeSats(totalZaps(zaps) / 1000)} sats in the last{" "}
|
||||
{dayjs.unix(zaps[zaps.length - 1].created_at).fromNow(true)}
|
||||
{dayjs.unix(events[events.length - 1].created_at).fromNow(true)}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
{zaps.map((event) => (
|
||||
{events.map((event) => (
|
||||
<ErrorBoundary key={event.id}>
|
||||
<Zap zapEvent={event} />
|
||||
</ErrorBoundary>
|
||||
|
Loading…
x
Reference in New Issue
Block a user