mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-19 03:51:34 +02:00
show event reactions
This commit is contained in:
@@ -27,6 +27,7 @@ import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
|
||||
import { deleteDatabase } from "./services/db";
|
||||
import { LoginNsecView } from "./views/login/nsec";
|
||||
import UserZapsTab from "./views/user/zaps";
|
||||
import PopularTab from "./views/home/popular";
|
||||
|
||||
const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
|
||||
let location = useLocation();
|
||||
@@ -125,6 +126,7 @@ const router = createBrowserRouter([
|
||||
{ path: "", element: <FollowingTab /> },
|
||||
{ path: "following", element: <FollowingTab /> },
|
||||
{ path: "discover", element: <DiscoverTab /> },
|
||||
// { path: "popular", element: <PopularTab /> },
|
||||
{ path: "global", element: <GlobalTab /> },
|
||||
],
|
||||
},
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
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 { Subject } from "./subject";
|
||||
|
||||
@@ -32,21 +32,8 @@ export class NostrSubscription {
|
||||
this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => {
|
||||
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) {
|
||||
this.relay.send(message);
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
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 { NoteContents } from "./note/note-contents";
|
||||
import { useUserContacts } from "../hooks/use-user-contacts";
|
||||
@@ -20,7 +20,7 @@ const EmbeddedNote = ({ note }: { note: NostrEvent }) => {
|
||||
const following = contacts?.contacts || [];
|
||||
|
||||
return (
|
||||
<LinkBox as={Card} variant="outline">
|
||||
<Card variant="outline">
|
||||
<CardHeader padding="2">
|
||||
<Flex flex="1" gap="2" alignItems="center" wrap="wrap">
|
||||
<UserAvatarLink pubkey={note.pubkey} size="xs" />
|
||||
@@ -30,14 +30,15 @@ const EmbeddedNote = ({ note }: { note: NostrEvent }) => {
|
||||
</Heading>
|
||||
<UserDnsIdentityIcon pubkey={note.pubkey} onlyIcon />
|
||||
{!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>
|
||||
</CardHeader>
|
||||
<CardBody px="2" pt="0" pb="2">
|
||||
<NoteContents event={note} trusted={following.includes(note.pubkey)} maxHeight={200} />
|
||||
</CardBody>
|
||||
<LinkOverlay as={RouterLink} to={`/n/${normalizeToBech32(note.id, Bech32Prefix.Note)}`} />
|
||||
</LinkBox>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -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",
|
||||
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,
|
||||
});
|
||||
|
@@ -19,6 +19,7 @@ import { buildReply, buildShare } from "../../helpers/nostr-event";
|
||||
import { UserDnsIdentityIcon } from "../user-dns-identity";
|
||||
import { convertTimestampToDate } from "../../helpers/date";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import NoteReactions from "./note-reactions";
|
||||
|
||||
export type NoteProps = {
|
||||
event: NostrEvent;
|
||||
@@ -72,6 +73,7 @@ export const Note = React.memo(({ event, maxHeight }: NoteProps) => {
|
||||
size="xs"
|
||||
isDisabled={account.readonly}
|
||||
/>
|
||||
<NoteReactions noteId={event.id} />
|
||||
<Box flexGrow={1} />
|
||||
<NoteRelays event={event} size="xs" />
|
||||
<NoteMenu event={event} />
|
||||
|
184
src/components/note/note-reactions.tsx
Normal file
184
src/components/note/note-reactions.tsx
Normal 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;
|
@@ -13,7 +13,6 @@ import {
|
||||
PopoverFooter,
|
||||
} from "@chakra-ui/react";
|
||||
import { nostrPostAction } from "../../classes/nostr-post-action";
|
||||
import { NostrRequest } from "../../classes/nostr-request";
|
||||
import { getEventRelays, handleEventFromRelay } from "../../services/event-relays";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { RelayIcon, SearchIcon } from "../icons";
|
||||
@@ -28,19 +27,8 @@ export type NoteRelaysProps = Omit<IconButtonProps, "icon" | "aria-label"> & {
|
||||
|
||||
export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
|
||||
const eventRelays = useSubject(getEventRelays(event.id));
|
||||
const readRelays = useReadRelayUrls();
|
||||
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 broadcast = useCallback(() => {
|
||||
const missingRelays = writeRelays.filter((url) => !eventRelays.includes(url));
|
||||
@@ -77,9 +65,6 @@ export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
|
||||
</PopoverBody>
|
||||
<PopoverFooter>
|
||||
<Flex gap="2">
|
||||
<Button size="xs" onClick={queryRelays} isLoading={querying} leftIcon={<SearchIcon />}>
|
||||
Search
|
||||
</Button>
|
||||
<Button size="xs" onClick={broadcast} isLoading={broadcasting} leftIcon={<RelayIcon />}>
|
||||
Broadcast
|
||||
</Button>
|
||||
|
@@ -1,24 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NostrRequest } from "../../classes/nostr-request";
|
||||
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 { NoteLink } from "../note-link";
|
||||
|
||||
const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => {
|
||||
const relays = useReadRelayUrls(relay ? [relay] : []);
|
||||
|
||||
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("|")]);
|
||||
const { event, loading } = useSingleEvent(noteId, relays);
|
||||
|
||||
return event ? <EmbeddedNote note={event} /> : <NoteLink noteId={noteId} />;
|
||||
};
|
||||
|
@@ -22,8 +22,8 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
|
||||
const eTags = event.tags.filter(isETag);
|
||||
const pTags = event.tags.filter(isPTag);
|
||||
|
||||
const eventTags = eTags.map((t) => t[1]);
|
||||
const pubkeyTags = pTags.map((t) => t[1]);
|
||||
const events = eTags.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]));
|
||||
|
||||
let replyId = eTags.find((t) => t[3] === "reply")?.[1];
|
||||
@@ -57,8 +57,8 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
|
||||
}
|
||||
|
||||
return {
|
||||
pubkeyTags,
|
||||
eventTags,
|
||||
pubkeys,
|
||||
events,
|
||||
rootId,
|
||||
replyId,
|
||||
contentTagRefs,
|
||||
|
11
src/hooks/use-single-event.ts
Normal file
11
src/hooks/use-single-event.ts
Normal 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,
|
||||
};
|
||||
}
|
54
src/services/single-event.ts
Normal file
54
src/services/single-event.ts
Normal 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;
|
@@ -7,6 +7,7 @@ import { getReferences } from "../helpers/nostr-event";
|
||||
import userContactsService from "./user-contacts";
|
||||
import clientRelaysService from "./client-relays";
|
||||
import { Subject } from "../classes/subject";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
const subscription = new NostrMultiSubscription([], undefined, "user-followers");
|
||||
const subjects = new PubkeySubjectCache<string[]>();
|
||||
@@ -63,7 +64,7 @@ function flushRequests() {
|
||||
}
|
||||
|
||||
function receiveEvent(event: NostrEvent) {
|
||||
if (event.kind !== 3) return;
|
||||
if (event.kind !== Kind.Contacts) return;
|
||||
const follower = event.pubkey;
|
||||
|
||||
const refs = getReferences(event);
|
||||
|
@@ -68,10 +68,6 @@ export const DiscoverTab = () => {
|
||||
const pubkeys = useSubject(discover.pubkeys);
|
||||
const throttledPubkeys = useThrottle(pubkeys, 1000);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(discover);
|
||||
}, [discover]);
|
||||
|
||||
const { events, loading, loadMore } = useTimelineLoader(
|
||||
`${account.pubkey}-discover`,
|
||||
relays,
|
||||
|
@@ -4,6 +4,7 @@ import { Outlet, useMatches, useNavigate } from "react-router-dom";
|
||||
const tabs = [
|
||||
{ label: "Following", path: "/following" },
|
||||
{ label: "Discover", path: "/discover" },
|
||||
// { label: "Popular", path: "/popular" },
|
||||
{ label: "Global", path: "/global" },
|
||||
];
|
||||
|
||||
|
55
src/views/home/popular.tsx
Normal file
55
src/views/home/popular.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user