show reaction and zap count

This commit is contained in:
hzrd149 2023-02-22 22:45:16 -06:00
parent 343930ac21
commit f03aac1619
13 changed files with 240 additions and 71 deletions

View File

@ -13,7 +13,6 @@ import {
Heading,
IconButton,
Link,
Text,
} from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
@ -25,7 +24,7 @@ import { useUserContacts } from "../../hooks/use-user-contacts";
import { NoteRelays } from "./note-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserLink } from "../user-link";
import { LightningIcon, LikeIcon, ReplyIcon, ShareIcon } from "../icons";
import { ReplyIcon, ShareIcon } from "../icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import { buildReply, buildShare } from "../../helpers/nostr-event";
import { UserDnsIdentityIcon } from "../user-dns-identity";

View File

@ -14,15 +14,20 @@ import { Kind } from "nostr-tools";
import { useState } from "react";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { random } from "../../helpers/array";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useEventReactions from "../../hooks/use-event-reactions";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import eventReactionsService from "../../services/event-reactions";
import { getEventRelays } from "../../services/event-relays";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { DislikeIcon, LikeIcon } from "../icons";
export default function NoteLikeButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const reactions = useEventReactions(note.id) ?? [];
const [loading, setLoading] = useState(false);
const handleClick = async (reaction = "+") => {
@ -40,6 +45,7 @@ export default function NoteLikeButton({ note, ...props }: { note: NostrEvent }
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
eventReactionsService.handleEvent(signed);
}
setLoading(false);
};
@ -49,32 +55,35 @@ export default function NoteLikeButton({ note, ...props }: { note: NostrEvent }
handleClick(input);
};
const isLiked = reactions.some((event) => event.pubkey === account.pubkey);
return (
<Popover placement="bottom" trigger="hover" openDelay={500}>
<PopoverTrigger>
<Button
leftIcon={<LikeIcon />}
aria-label="Like Note"
title="Like Note"
onClick={() => handleClick("+")}
isLoading={loading}
{...props}
>
0
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<Flex gap="2">
<IconButton icon={<LikeIcon />} onClick={() => handleClick("+")} aria-label="like" />
<IconButton icon={<DislikeIcon />} onClick={() => handleClick("-")} aria-label="dislike" />
<IconButton icon={<span>🤙</span>} onClick={() => handleClick("🤙")} aria-label="different like" />
<IconButton icon={<span></span>} onClick={() => handleClick("❤️")} aria-label="different like" />
<Button onClick={customReaction}>Custom</Button>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
// <Popover placement="bottom" trigger="hover" openDelay={500}>
// <PopoverTrigger>
<Button
leftIcon={<LikeIcon />}
aria-label="Like Note"
title="Like Note"
onClick={() => handleClick("+")}
isLoading={loading}
colorScheme={isLiked ? "brand" : undefined}
{...props}
>
{reactions?.length ?? 0}
</Button>
// </PopoverTrigger>
// <PopoverContent>
// <PopoverArrow />
// <PopoverBody>
// <Flex gap="2">
// <IconButton icon={<LikeIcon />} onClick={() => handleClick("+")} aria-label="like" />
// <IconButton icon={<DislikeIcon />} onClick={() => handleClick("-")} aria-label="dislike" />
// <IconButton icon={<span>🤙</span>} onClick={() => handleClick("🤙")} aria-label="different like" />
// <IconButton icon={<span>❤️</span>} onClick={() => handleClick("❤️")} aria-label="different like" />
// <Button onClick={customReaction}>Custom</Button>
// </Flex>
// </PopoverBody>
// </PopoverContent>
// </Popover>
);
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import {
Modal,
ModalOverlay,
@ -11,9 +11,6 @@ import {
Flex,
ButtonGroup,
} from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { NostrRequest } from "../../classes/nostr-request";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
@ -22,30 +19,8 @@ import { convertTimestampToDate } from "../../helpers/date";
import { DislikeIcon, LikeIcon } from "../icons";
import { parseZapNote } from "../../helpers/nip57";
import { readableAmountInSats } from "../../helpers/bolt11";
function useEventReactions(noteId?: string) {
const relays = useReadRelayUrls();
const [events, setEvents] = useState<Record<string, NostrEvent>>({});
useEffect(() => {
if (noteId && relays.length > 0) {
setEvents({});
const handler = (e: NostrEvent) => setEvents((dir) => ({ ...dir, [e.id]: e }));
const request = new NostrRequest(relays);
request.onEvent.subscribe(handler);
request.start({ kinds: [Kind.Reaction, Kind.Zap], "#e": [noteId] });
return () => {
request.complete();
request.onEvent.unsubscribe(handler);
};
}
}, [noteId, relays.join("|"), setEvents]);
return {
reactions: Array.from(Object.values(events)).filter((e) => e.kind === Kind.Reaction),
zaps: Array.from(Object.values(events)).filter((e) => e.kind === Kind.Zap),
};
}
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";
function getReactionIcon(content: string) {
switch (content) {
@ -100,7 +75,8 @@ export default function NoteReactionsModal({
onClose,
noteId,
}: { noteId: string } & Omit<ModalProps, "children">) {
const { reactions, zaps } = useEventReactions(noteId);
const zaps = useEventZaps(noteId, [], true) ?? [];
const reactions = useEventReactions(noteId, [], true) ?? [];
const [selected, setSelected] = useState("reactions");
return (

View File

@ -1,10 +1,14 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { readableAmountInSats } from "../../helpers/bolt11";
import { totalZaps } from "../../helpers/nip57";
import useEventZaps from "../../hooks/use-event-zaps";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { NostrEvent } from "../../types/nostr-event";
import { LightningIcon } from "../icons";
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const metadata = useUserMetadata(note.pubkey);
const zaps = useEventZaps(note.id, [], true) ?? [];
return (
<Button
@ -14,7 +18,7 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } &
{...props}
isDisabled
>
0
{readableAmountInSats(totalZaps(zaps), false)}
</Button>
);
}

View File

@ -26,17 +26,18 @@ export function parsePaymentRequest(paymentRequest: string): ParsedInvoice {
return {
paymentRequest: decoded.paymentRequest,
description: decoded.sections.find(isDescription)?.value ?? "",
amount: decoded.sections.find(isAmount)?.value,
amount: parseInt(decoded.sections.find(isAmount)?.value ?? "0"),
timestamp: convertTimestampToDate(timestamp),
expiry: convertTimestampToDate(timestamp + decoded.expiry),
};
}
export function readableAmountInSats(amount: number) {
const amountInSats = amount / 1000;
export function readableAmountInSats(amount: number, includeSats = true) {
const amountInSats = Math.round(amount / 1000);
const end = includeSats ? " sats" : "";
if (amountInSats > 1000000) {
return `${amountInSats / 1000000}M sats`;
return `${amountInSats / 1000000}M` + end;
} else if (amountInSats > 1000) {
return `${amountInSats / 1000}K sats`;
} else return `${amountInSats} sats`;
return `${amountInSats / 1000}K` + end;
} else return amountInSats + end;
}

View File

@ -43,6 +43,20 @@ 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 parseZapNote(event: NostrEvent) {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag");

View File

@ -0,0 +1,15 @@
import { useMemo } from "react";
import eventReactionsService from "../services/event-reactions";
import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
export default function useEventReactions(eventId: string, additionalRelays: string[] = [], alwaysFetch = true) {
const relays = useReadRelayUrls(additionalRelays);
const subject = useMemo(
() => eventReactionsService.requestReactions(eventId, relays, alwaysFetch),
[eventId, relays.join("|"), alwaysFetch]
);
return useSubject(subject);
}

View File

@ -0,0 +1,15 @@
import { useMemo } from "react";
import eventZapsService from "../services/event-zaps";
import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
export default function useEventZaps(eventId: string, additionalRelays: string[] = [], alwaysFetch = true) {
const relays = useReadRelayUrls(additionalRelays);
const subject = useMemo(
() => eventZapsService.requestZaps(eventId, relays, alwaysFetch),
[eventId, relays.join("|"), alwaysFetch]
);
return useSubject(subject);
}

View File

@ -2,16 +2,17 @@ import { useEffect, useState } from "react";
import { PersistentSubject, Subject } from "../classes/subject";
function useSubject<Value extends unknown>(subject: PersistentSubject<Value>): Value;
function useSubject<Value extends unknown>(subject: Subject<Value>): Value | undefined;
function useSubject<Value extends unknown>(subject: Subject<Value>) {
const [value, setValue] = useState(subject.value);
function useSubject<Value extends unknown>(subject?: PersistentSubject<Value>): Value | undefined;
function useSubject<Value extends unknown>(subject?: Subject<Value>): Value | undefined;
function useSubject<Value extends unknown>(subject?: Subject<Value>) {
const [value, setValue] = useState(subject?.value);
useEffect(() => {
const handler = (value: Value) => setValue(value);
setValue(subject.value);
subject.subscribe(handler);
setValue(subject?.value);
subject?.subscribe(handler);
return () => {
subject.unsubscribe(handler);
subject?.unsubscribe(handler);
};
}, [subject, setValue]);

View File

@ -0,0 +1,67 @@
import { Kind } from "nostr-tools";
import { NostrRequest } from "../classes/nostr-request";
import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map";
import { getReferences } from "../helpers/nostr-event";
import { NostrEvent } from "../types/nostr-event";
type eventId = string;
type relay = string;
class EventReactionsService {
subjects = new SuperMap<eventId, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
pending = new SuperMap<eventId, Set<relay>>(() => new Set());
requestReactions(eventId: string, relays: relay[], alwaysFetch = true) {
const subject = this.subjects.get(eventId);
if (!subject.value || alwaysFetch) {
for (const relay of relays) {
this.pending.get(eventId).add(relay);
}
}
return subject;
}
handleEvent(event: NostrEvent) {
if (event.kind !== Kind.Reaction) return;
const refs = getReferences(event);
const id = refs.events[0];
if (!id) return;
const subject = this.subjects.get(id);
if (!subject.value) {
subject.next([event]);
} else if (!subject.value.some((e) => e.id === event.id)) {
subject.next([...subject.value, event]);
}
}
batchRequests() {
if (this.pending.size === 0) return;
const idsFromRelays: Record<relay, eventId[]> = {};
for (const [id, relays] of this.pending) {
for (const relay of relays) {
idsFromRelays[relay] = idsFromRelays[relay] ?? [];
idsFromRelays[relay].push(id);
}
}
for (const [relay, ids] of Object.entries(idsFromRelays)) {
const request = new NostrRequest([relay]);
request.onEvent.subscribe(this.handleEvent, this);
request.start({ "#e": ids, kinds: [Kind.Reaction] });
}
this.pending.clear();
}
}
const eventReactionsService = new EventReactionsService();
setInterval(() => {
eventReactionsService.batchRequests();
}, 1000 * 2);
export default eventReactionsService;

View File

@ -0,0 +1,67 @@
import { Kind } from "nostr-tools";
import { NostrRequest } from "../classes/nostr-request";
import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map";
import { getReferences } from "../helpers/nostr-event";
import { NostrEvent } from "../types/nostr-event";
type eventId = string;
type relay = string;
class EventZapsService {
subjects = new SuperMap<eventId, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
pending = new SuperMap<eventId, Set<relay>>(() => new Set());
requestZaps(eventId: string, relays: relay[], alwaysFetch = true) {
const subject = this.subjects.get(eventId);
if (!subject.value || alwaysFetch) {
for (const relay of relays) {
this.pending.get(eventId).add(relay);
}
}
return subject;
}
handleEvent(event: NostrEvent) {
if (event.kind !== Kind.Zap) return;
const refs = getReferences(event);
const id = refs.events[0];
if (!id) return;
const subject = this.subjects.get(id);
if (!subject.value) {
subject.next([event]);
} else if (!subject.value.some((e) => e.id === event.id)) {
subject.next([...subject.value, event]);
}
}
batchRequests() {
if (this.pending.size === 0) return;
const idsFromRelays: Record<relay, eventId[]> = {};
for (const [id, relays] of this.pending) {
for (const relay of relays) {
idsFromRelays[relay] = idsFromRelays[relay] ?? [];
idsFromRelays[relay].push(id);
}
}
for (const [relay, ids] of Object.entries(idsFromRelays)) {
const request = new NostrRequest([relay]);
request.onEvent.subscribe(this.handleEvent, this);
request.start({ "#e": ids, kinds: [Kind.Zap] });
}
this.pending.clear();
}
}
const eventZapsService = new EventZapsService();
setInterval(() => {
eventZapsService.batchRequests();
}, 1000 * 2);
export default eventZapsService;

View File

@ -6,6 +6,7 @@ const settings = {
blurImages: new PersistentSubject(true),
autoShowMedia: new PersistentSubject(true),
proxyUserMedia: new PersistentSubject(false),
prefetchReactions: new PersistentSubject(false),
accounts: new PersistentSubject<Account[]>([]),
};

View File

@ -11,7 +11,7 @@ declare module "light-bolt11-decoder" {
export type AmountSection = {
name: "amount";
letters: string;
value: number;
value: string;
};
export type SeparatorSection = {
name: "separator";