mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-29 11:12:12 +01:00
show reaction and zap count
This commit is contained in:
parent
343930ac21
commit
f03aac1619
@ -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";
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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");
|
||||
|
15
src/hooks/use-event-reactions.ts
Normal file
15
src/hooks/use-event-reactions.ts
Normal 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);
|
||||
}
|
15
src/hooks/use-event-zaps.ts
Normal file
15
src/hooks/use-event-zaps.ts
Normal 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);
|
||||
}
|
@ -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]);
|
||||
|
||||
|
67
src/services/event-reactions.ts
Normal file
67
src/services/event-reactions.ts
Normal 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;
|
67
src/services/event-zaps.ts
Normal file
67
src/services/event-zaps.ts
Normal 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;
|
@ -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[]>([]),
|
||||
};
|
||||
|
||||
|
2
src/types/light-bolt11-decoder.d.ts
vendored
2
src/types/light-bolt11-decoder.d.ts
vendored
@ -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";
|
||||
|
Loading…
x
Reference in New Issue
Block a user