show event reactions

This commit is contained in:
hzrd149
2023-02-21 09:19:00 -06:00
parent 66df230b22
commit 27cefc1c4e
15 changed files with 336 additions and 58 deletions

View File

@@ -27,6 +27,7 @@ import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
import { deleteDatabase } from "./services/db"; import { deleteDatabase } from "./services/db";
import { LoginNsecView } from "./views/login/nsec"; import { LoginNsecView } from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps"; import UserZapsTab from "./views/user/zaps";
import PopularTab from "./views/home/popular";
const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => { const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
let location = useLocation(); let location = useLocation();
@@ -125,6 +126,7 @@ const router = createBrowserRouter([
{ path: "", element: <FollowingTab /> }, { path: "", element: <FollowingTab /> },
{ path: "following", element: <FollowingTab /> }, { path: "following", element: <FollowingTab /> },
{ path: "discover", element: <DiscoverTab /> }, { path: "discover", element: <DiscoverTab /> },
// { path: "popular", element: <PopularTab /> },
{ path: "global", element: <GlobalTab /> }, { path: "global", element: <GlobalTab /> },
], ],
}, },

View File

@@ -1,6 +1,6 @@
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrQuery } from "../types/nostr-query"; import { NostrOutgoingMessage, NostrQuery } from "../types/nostr-query";
import { IncomingEOSE, IncomingEvent, Relay } from "./relay"; import { IncomingEOSE, Relay } from "./relay";
import relayPoolService from "../services/relay-pool"; import relayPoolService from "../services/relay-pool";
import { Subject } from "./subject"; import { Subject } from "./subject";
@@ -32,21 +32,8 @@ export class NostrSubscription {
this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => { this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => {
if (this.state === NostrSubscription.OPEN) next(eose); if (this.state === NostrSubscription.OPEN) next(eose);
}); });
// this.relay.onEvent.subscribe(this.handleEvent.bind(this));
// this.relay.onEOSE.subscribe(this.handleEOSE.bind(this));
} }
// private handleEvent(event: IncomingEvent) {
// if (this.state === NostrSubscription.OPEN && event.subId === this.id) {
// this.onEvent.next(event.body);
// }
// }
// private handleEOSE(eose: IncomingEOSE) {
// if (this.state === NostrSubscription.OPEN && eose.subId === this.id) {
// this.onEOSE.next(eose);
// }
// }
send(message: NostrOutgoingMessage) { send(message: NostrOutgoingMessage) {
this.relay.send(message); this.relay.send(message);
} }

View File

@@ -1,6 +1,6 @@
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import moment from "moment"; import moment from "moment";
import { Card, CardBody, CardHeader, Flex, Heading, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react"; import { Card, CardBody, CardHeader, Flex, Heading, Link } from "@chakra-ui/react";
import { useIsMobile } from "../hooks/use-is-mobile"; import { useIsMobile } from "../hooks/use-is-mobile";
import { NoteContents } from "./note/note-contents"; import { NoteContents } from "./note/note-contents";
import { useUserContacts } from "../hooks/use-user-contacts"; import { useUserContacts } from "../hooks/use-user-contacts";
@@ -20,7 +20,7 @@ const EmbeddedNote = ({ note }: { note: NostrEvent }) => {
const following = contacts?.contacts || []; const following = contacts?.contacts || [];
return ( return (
<LinkBox as={Card} variant="outline"> <Card variant="outline">
<CardHeader padding="2"> <CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center" wrap="wrap"> <Flex flex="1" gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={note.pubkey} size="xs" /> <UserAvatarLink pubkey={note.pubkey} size="xs" />
@@ -30,14 +30,15 @@ const EmbeddedNote = ({ note }: { note: NostrEvent }) => {
</Heading> </Heading>
<UserDnsIdentityIcon pubkey={note.pubkey} onlyIcon /> <UserDnsIdentityIcon pubkey={note.pubkey} onlyIcon />
{!isMobile && <Flex grow={1} />} {!isMobile && <Flex grow={1} />}
<Text whiteSpace="nowrap">{moment(convertTimestampToDate(note.created_at)).fromNow()}</Text> <Link as={RouterLink} to={`/n/${normalizeToBech32(note.id, Bech32Prefix.Note)}`} whiteSpace="nowrap">
{moment(convertTimestampToDate(note.created_at)).fromNow()}
</Link>
</Flex> </Flex>
</CardHeader> </CardHeader>
<CardBody px="2" pt="0" pb="2"> <CardBody px="2" pt="0" pb="2">
<NoteContents event={note} trusted={following.includes(note.pubkey)} maxHeight={200} /> <NoteContents event={note} trusted={following.includes(note.pubkey)} maxHeight={200} />
</CardBody> </CardBody>
<LinkOverlay as={RouterLink} to={`/n/${normalizeToBech32(note.id, Bech32Prefix.Note)}`} /> </Card>
</LinkBox>
); );
}; };

View File

@@ -185,3 +185,15 @@ export const UndoIcon = createIcon({
d: "M5.828 7l2.536 2.536L6.95 10.95 2 6l4.95-4.95 1.414 1.414L5.828 5H13a8 8 0 1 1 0 16H4v-2h9a6 6 0 1 0 0-12H5.828z", d: "M5.828 7l2.536 2.536L6.95 10.95 2 6l4.95-4.95 1.414 1.414L5.828 5H13a8 8 0 1 1 0 16H4v-2h9a6 6 0 1 0 0-12H5.828z",
defaultProps, defaultProps,
}); });
export const LikeIcon = createIcon({
displayName: "UndoIcon",
d: "M14.6 8H21a2 2 0 0 1 2 2v2.104a2 2 0 0 1-.15.762l-3.095 7.515a1 1 0 0 1-.925.619H2a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1h3.482a1 1 0 0 0 .817-.423L11.752.85a.5.5 0 0 1 .632-.159l1.814.907a2.5 2.5 0 0 1 1.305 2.853L14.6 8zM7 10.588V19h11.16L21 12.104V10h-6.4a2 2 0 0 1-1.938-2.493l.903-3.548a.5.5 0 0 0-.261-.571l-.661-.33-4.71 6.672c-.25.354-.57.644-.933.858zM5 11H3v8h2v-8z",
defaultProps,
});
export const DislikeIcon = createIcon({
displayName: "UndoIcon",
d: "M9.4 16H3a2 2 0 0 1-2-2v-2.104a2 2 0 0 1 .15-.762L4.246 3.62A1 1 0 0 1 5.17 3H22a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-3.482a1 1 0 0 0-.817.423l-5.453 7.726a.5.5 0 0 1-.632.159L9.802 22.4a2.5 2.5 0 0 1-1.305-2.853L9.4 16zm7.6-2.588V5H5.84L3 11.896V14h6.4a2 2 0 0 1 1.938 2.493l-.903 3.548a.5.5 0 0 0 .261.571l.661.33 4.71-6.672c.25-.354.57-.644.933-.858zM19 13h2V5h-2v8z",
defaultProps,
});

View File

@@ -19,6 +19,7 @@ import { buildReply, buildShare } from "../../helpers/nostr-event";
import { UserDnsIdentityIcon } from "../user-dns-identity"; import { UserDnsIdentityIcon } from "../user-dns-identity";
import { convertTimestampToDate } from "../../helpers/date"; import { convertTimestampToDate } from "../../helpers/date";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import NoteReactions from "./note-reactions";
export type NoteProps = { export type NoteProps = {
event: NostrEvent; event: NostrEvent;
@@ -72,6 +73,7 @@ export const Note = React.memo(({ event, maxHeight }: NoteProps) => {
size="xs" size="xs"
isDisabled={account.readonly} isDisabled={account.readonly}
/> />
<NoteReactions noteId={event.id} />
<Box flexGrow={1} /> <Box flexGrow={1} />
<NoteRelays event={event} size="xs" /> <NoteRelays event={event} size="xs" />
<NoteMenu event={event} /> <NoteMenu event={event} />

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useState } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
ModalProps,
Text,
useDisclosure,
Flex,
ButtonGroup,
IconButton,
} from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { NostrRequest } from "../../classes/nostr-request";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { UserLink } from "../user-link";
import moment from "moment";
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),
};
}
function getReactionIcon(content: string) {
switch (content) {
case "+":
return <LikeIcon />;
case "-":
return <DislikeIcon />;
default:
return content;
}
}
const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => (
<Flex gap="2">
<Text>{getReactionIcon(event.content)}</Text>
<Flex overflow="hidden" gap="2">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} />
</Flex>
<Text ml="auto" flexShrink={0}>
{moment(convertTimestampToDate(event.created_at)).fromNow()}
</Text>
</Flex>
));
const ZapEvent = React.memo(({ event }: { event: NostrEvent }) => {
const { payment, request } = parseZapNote(event);
if (!payment.amount) return null;
return (
<Flex gap="2">
<Text>{readableAmountInSats(payment.amount)}</Text>
<Flex overflow="hidden" gap="2">
<UserAvatarLink pubkey={request.pubkey} size="xs" />
<UserLink pubkey={request.pubkey} />
</Flex>
<Text>{request.content}</Text>
<Text ml="auto" flexShrink={0}>
{moment(convertTimestampToDate(event.created_at)).fromNow()}
</Text>
</Flex>
);
});
function sortEvents(a: NostrEvent, b: NostrEvent) {
return b.created_at - a.created_at;
}
export const NoteReactionsModal = ({ isOpen, onClose, noteId }: { noteId: string } & Omit<ModalProps, "children">) => {
const { reactions, zaps } = useEventReactions(noteId);
const [selected, setSelected] = useState("reactions");
const [sending, setSending] = useState(false);
const sendReaction = async (content: string) => {
setSending(true);
const event: DraftNostrEvent = {
kind: Kind.Reaction,
content,
created_at: moment().unix(),
tags: [["e", noteId]],
};
setSending(false);
};
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody>
<Flex direction="column" gap="2">
<ButtonGroup>
<Button size="sm" variant="outline" onClick={() => setSelected("reactions")}>
Reactions ({reactions.length})
</Button>
<Button size="sm" variant="outline" onClick={() => setSelected("zaps")}>
Zaps ({zaps.length})
</Button>
</ButtonGroup>
{selected === "reactions" &&
reactions.sort(sortEvents).map((event) => <ReactionEvent key={event.id} event={event} />)}
{selected === "zaps" && zaps.sort(sortEvents).map((event) => <ZapEvent key={event.id} event={event} />)}
</Flex>
</ModalBody>
<ModalFooter display="flex" gap="2">
<IconButton
icon={<LikeIcon />}
aria-label="Like Note"
title="Like Note"
size="sm"
variant="outline"
isDisabled
/>
<IconButton
icon={<DislikeIcon />}
aria-label="Dislike Note"
title="Dislike Note"
size="sm"
variant="outline"
isDisabled
/>
<Button size="sm" variant="outline" isDisabled>
🤙
</Button>
<Button size="sm" variant="outline" mr="auto" isDisabled>
Custom
</Button>
<Button colorScheme="blue" onClick={onClose} size="sm">
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
const NoteReactions = ({ noteId }: { noteId: string }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<ButtonGroup size="xs" isAttached>
<IconButton icon={<LikeIcon />} aria-label="Like Note" title="Like Note" />
<Button onClick={onOpen}>Reactions</Button>
</ButtonGroup>
{isOpen && <NoteReactionsModal noteId={noteId} isOpen={isOpen} onClose={onClose} />}
</>
);
};
export default NoteReactions;

View File

@@ -13,7 +13,6 @@ import {
PopoverFooter, PopoverFooter,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { nostrPostAction } from "../../classes/nostr-post-action"; import { nostrPostAction } from "../../classes/nostr-post-action";
import { NostrRequest } from "../../classes/nostr-request";
import { getEventRelays, handleEventFromRelay } from "../../services/event-relays"; import { getEventRelays, handleEventFromRelay } from "../../services/event-relays";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import { RelayIcon, SearchIcon } from "../icons"; import { RelayIcon, SearchIcon } from "../icons";
@@ -28,19 +27,8 @@ export type NoteRelaysProps = Omit<IconButtonProps, "icon" | "aria-label"> & {
export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => { export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
const eventRelays = useSubject(getEventRelays(event.id)); const eventRelays = useSubject(getEventRelays(event.id));
const readRelays = useReadRelayUrls();
const writeRelays = useWriteRelayUrls(); const writeRelays = useWriteRelayUrls();
const [querying, setQuerying] = useState(false);
const queryRelays = useCallback(() => {
setQuerying(true);
const request = new NostrRequest(readRelays);
request.start({ ids: [event.id] });
request.onComplete.then(() => {
setQuerying(false);
});
}, []);
const [broadcasting, setBroadcasting] = useState(false); const [broadcasting, setBroadcasting] = useState(false);
const broadcast = useCallback(() => { const broadcast = useCallback(() => {
const missingRelays = writeRelays.filter((url) => !eventRelays.includes(url)); const missingRelays = writeRelays.filter((url) => !eventRelays.includes(url));
@@ -77,9 +65,6 @@ export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
</PopoverBody> </PopoverBody>
<PopoverFooter> <PopoverFooter>
<Flex gap="2"> <Flex gap="2">
<Button size="xs" onClick={queryRelays} isLoading={querying} leftIcon={<SearchIcon />}>
Search
</Button>
<Button size="xs" onClick={broadcast} isLoading={broadcasting} leftIcon={<RelayIcon />}> <Button size="xs" onClick={broadcast} isLoading={broadcasting} leftIcon={<RelayIcon />}>
Broadcast Broadcast
</Button> </Button>

View File

@@ -1,24 +1,11 @@
import { useEffect, useState } from "react";
import { NostrRequest } from "../../classes/nostr-request";
import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { NostrEvent } from "../../types/nostr-event"; import useSingleEvent from "../../hooks/use-single-event";
import EmbeddedNote from "../embeded-note"; import EmbeddedNote from "../embeded-note";
import { NoteLink } from "../note-link"; import { NoteLink } from "../note-link";
const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => { const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => {
const relays = useReadRelayUrls(relay ? [relay] : []); const relays = useReadRelayUrls(relay ? [relay] : []);
const { event, loading } = useSingleEvent(noteId, relays);
const [event, setEvent] = useState<NostrEvent>();
useEffect(() => {
if (!noteId || relays.length === 0) return;
const request = new NostrRequest(relays);
request.onEvent.subscribe((e) => setEvent(e));
request.start({ ids: [noteId] });
return () => {
request.complete();
};
}, [noteId, relays.join("|")]);
return event ? <EmbeddedNote note={event} /> : <NoteLink noteId={noteId} />; return event ? <EmbeddedNote note={event} /> : <NoteLink noteId={noteId} />;
}; };

View File

@@ -22,8 +22,8 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
const eTags = event.tags.filter(isETag); const eTags = event.tags.filter(isETag);
const pTags = event.tags.filter(isPTag); const pTags = event.tags.filter(isPTag);
const eventTags = eTags.map((t) => t[1]); const events = eTags.map((t) => t[1]);
const pubkeyTags = pTags.map((t) => t[1]); const pubkeys = pTags.map((t) => t[1]);
const contentTagRefs = Array.from(event.content.matchAll(/#\[(\d+)\]/gi)).map((m) => parseInt(m[1])); const contentTagRefs = Array.from(event.content.matchAll(/#\[(\d+)\]/gi)).map((m) => parseInt(m[1]));
let replyId = eTags.find((t) => t[3] === "reply")?.[1]; let replyId = eTags.find((t) => t[3] === "reply")?.[1];
@@ -57,8 +57,8 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
} }
return { return {
pubkeyTags, pubkeys,
eventTags, events,
rootId, rootId,
replyId, replyId,
contentTagRefs, contentTagRefs,

View File

@@ -0,0 +1,11 @@
import { useAsync } from "react-use";
import singleEventService from "../services/single-event";
export default function useSingleEvent(id: string, relays: string[]) {
const { loading, value: event } = useAsync(() => singleEventService.requestEvent(id, relays), [id, relays.join("|")]);
return {
event,
loading,
};
}

View File

@@ -0,0 +1,54 @@
import createDefer, { Deferred } from "../classes/deferred";
import { NostrRequest } from "../classes/nostr-request";
import { NostrEvent } from "../types/nostr-event";
class SingleEventService {
eventCache = new Map<string, NostrEvent>();
pending = new Map<string, string[]>();
pendingPromises = new Map<string, Deferred<NostrEvent>>();
async requestEvent(id: string, relays: string[]) {
if (this.eventCache.has(id)) {
return this.eventCache.get(id);
}
this.pending.set(id, this.pending.get(id)?.concat(relays) ?? relays);
const deferred = createDefer<NostrEvent>();
this.pendingPromises.set(id, deferred);
return deferred;
}
handleEvent(event: NostrEvent) {
this.eventCache.set(event.id, event);
if (this.pendingPromises.has(event.id)) {
this.pendingPromises.get(event.id)?.resolve(event);
this.pendingPromises.delete(event.id);
}
}
batchRequests() {
if (this.pending.size === 0) return;
const idsFromRelays: Record<string, string[]> = {};
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({ ids });
}
this.pending.clear();
}
}
const singleEventService = new SingleEventService();
setInterval(() => {
singleEventService.batchRequests();
}, 1000);
export default singleEventService;

View File

@@ -7,6 +7,7 @@ import { getReferences } from "../helpers/nostr-event";
import userContactsService from "./user-contacts"; import userContactsService from "./user-contacts";
import clientRelaysService from "./client-relays"; import clientRelaysService from "./client-relays";
import { Subject } from "../classes/subject"; import { Subject } from "../classes/subject";
import { Kind } from "nostr-tools";
const subscription = new NostrMultiSubscription([], undefined, "user-followers"); const subscription = new NostrMultiSubscription([], undefined, "user-followers");
const subjects = new PubkeySubjectCache<string[]>(); const subjects = new PubkeySubjectCache<string[]>();
@@ -63,7 +64,7 @@ function flushRequests() {
} }
function receiveEvent(event: NostrEvent) { function receiveEvent(event: NostrEvent) {
if (event.kind !== 3) return; if (event.kind !== Kind.Contacts) return;
const follower = event.pubkey; const follower = event.pubkey;
const refs = getReferences(event); const refs = getReferences(event);

View File

@@ -68,10 +68,6 @@ export const DiscoverTab = () => {
const pubkeys = useSubject(discover.pubkeys); const pubkeys = useSubject(discover.pubkeys);
const throttledPubkeys = useThrottle(pubkeys, 1000); const throttledPubkeys = useThrottle(pubkeys, 1000);
useEffect(() => {
console.log(discover);
}, [discover]);
const { events, loading, loadMore } = useTimelineLoader( const { events, loading, loadMore } = useTimelineLoader(
`${account.pubkey}-discover`, `${account.pubkey}-discover`,
relays, relays,

View File

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

View File

@@ -0,0 +1,55 @@
import { useMemo } from "react";
import { Box, Button, Flex, Text } from "@chakra-ui/react";
import moment from "moment";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useThrottle } from "react-use";
import { Kind } from "nostr-tools";
import { parseZapNote } from "../../helpers/nip57";
import { NoteLink } from "../../components/note-link";
export default function PopularTab() {
useAppTitle("popular");
const relays = useReadRelayUrls();
const {
loading,
events: zaps,
loadMore,
} = useTimelineLoader(
"popular-zaps",
relays,
{ since: moment().subtract(1, "hour").unix(), kinds: [Kind.Zap] },
{ pageSize: moment.duration(1, "hour").asSeconds() }
);
const throttledZaps = useThrottle(zaps, 1000);
const groupedZaps = useMemo(() => {
const dir: Record<string, ReturnType<typeof parseZapNote>[]> = {};
for (const zap of throttledZaps) {
try {
const parsed = parseZapNote(zap);
if (!parsed.eventId) continue;
dir[parsed.eventId] = dir[parsed.eventId] || [];
dir[parsed.eventId].push(parsed);
} catch (e) {}
}
return dir;
}, [throttledZaps]);
return (
<Flex direction="column" gap="2">
<Button onClick={() => loadMore()} isLoading={loading}>
Load More
</Button>
{Array.from(Object.entries(groupedZaps)).map(([eventId, parsedZaps]) => (
<Box key={eventId}>
<Text>{parsedZaps.length}</Text>
<NoteLink noteId={eventId} />
</Box>
))}
</Flex>
);
}