add zap buttons to chat messages

This commit is contained in:
hzrd149 2023-07-03 07:18:21 -05:00
parent b1d11c40cc
commit e7df2de1c8
9 changed files with 170 additions and 149 deletions

View File

@ -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>

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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}
/>

View File

@ -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} />;
}

View 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>
);
}

View File

@ -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,

View 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>
);
}

View File

@ -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>