From 27cefc1c4eec6b3a3ba70241d8987af65a1499df Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Tue, 21 Feb 2023 09:19:00 -0600 Subject: [PATCH] show event reactions --- src/app.tsx | 2 + src/classes/nostr-subscription.ts | 15 +- src/components/embeded-note.tsx | 11 +- src/components/icons.tsx | 12 ++ src/components/note/index.tsx | 2 + src/components/note/note-reactions.tsx | 184 +++++++++++++++++++++++++ src/components/note/note-relays.tsx | 15 -- src/components/note/quote-note.tsx | 17 +-- src/helpers/nostr-event.ts | 8 +- src/hooks/use-single-event.ts | 11 ++ src/services/single-event.ts | 54 ++++++++ src/services/user-followers.ts | 3 +- src/views/home/discover-tab.tsx | 4 - src/views/home/index.tsx | 1 + src/views/home/popular.tsx | 55 ++++++++ 15 files changed, 336 insertions(+), 58 deletions(-) create mode 100644 src/components/note/note-reactions.tsx create mode 100644 src/hooks/use-single-event.ts create mode 100644 src/services/single-event.ts create mode 100644 src/views/home/popular.tsx diff --git a/src/app.tsx b/src/app.tsx index b2f8ed7e0..b07047960 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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: }, { path: "following", element: }, { path: "discover", element: }, + // { path: "popular", element: }, { path: "global", element: }, ], }, diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index 2bdd9382b..c9ac5b00c 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -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); } diff --git a/src/components/embeded-note.tsx b/src/components/embeded-note.tsx index 200c56da5..ecef36a3e 100644 --- a/src/components/embeded-note.tsx +++ b/src/components/embeded-note.tsx @@ -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 ( - + @@ -30,14 +30,15 @@ const EmbeddedNote = ({ note }: { note: NostrEvent }) => { {!isMobile && } - {moment(convertTimestampToDate(note.created_at)).fromNow()} + + {moment(convertTimestampToDate(note.created_at)).fromNow()} + - - + ); }; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 7ec3e8019..ee6923404 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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, +}); diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 1256b3287..85d7f186a 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -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} /> + diff --git a/src/components/note/note-reactions.tsx b/src/components/note/note-reactions.tsx new file mode 100644 index 000000000..e0adae31a --- /dev/null +++ b/src/components/note/note-reactions.tsx @@ -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>({}); + + 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 ; + case "-": + return ; + default: + return content; + } +} + +const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => ( + + {getReactionIcon(event.content)} + + + + + + {moment(convertTimestampToDate(event.created_at)).fromNow()} + + +)); + +const ZapEvent = React.memo(({ event }: { event: NostrEvent }) => { + const { payment, request } = parseZapNote(event); + + if (!payment.amount) return null; + + return ( + + {readableAmountInSats(payment.amount)} + + + + + {request.content} + + {moment(convertTimestampToDate(event.created_at)).fromNow()} + + + ); +}); + +function sortEvents(a: NostrEvent, b: NostrEvent) { + return b.created_at - a.created_at; +} + +export const NoteReactionsModal = ({ isOpen, onClose, noteId }: { noteId: string } & Omit) => { + 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 ( + + + + + + + + + + + {selected === "reactions" && + reactions.sort(sortEvents).map((event) => )} + {selected === "zaps" && zaps.sort(sortEvents).map((event) => )} + + + + + } + aria-label="Like Note" + title="Like Note" + size="sm" + variant="outline" + isDisabled + /> + } + aria-label="Dislike Note" + title="Dislike Note" + size="sm" + variant="outline" + isDisabled + /> + + + + + + + ); +}; + +const NoteReactions = ({ noteId }: { noteId: string }) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + + return ( + <> + + } aria-label="Like Note" title="Like Note" /> + + + {isOpen && } + + ); +}; + +export default NoteReactions; diff --git a/src/components/note/note-relays.tsx b/src/components/note/note-relays.tsx index a3c6cfe3a..13facbd38 100644 --- a/src/components/note/note-relays.tsx +++ b/src/components/note/note-relays.tsx @@ -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 & { 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) => { - diff --git a/src/components/note/quote-note.tsx b/src/components/note/quote-note.tsx index 396d022f1..c0dcf448c 100644 --- a/src/components/note/quote-note.tsx +++ b/src/components/note/quote-note.tsx @@ -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(); - - 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 ? : ; }; diff --git a/src/helpers/nostr-event.ts b/src/helpers/nostr-event.ts index 96eeacd3f..34360e16b 100644 --- a/src/helpers/nostr-event.ts +++ b/src/helpers/nostr-event.ts @@ -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, diff --git a/src/hooks/use-single-event.ts b/src/hooks/use-single-event.ts new file mode 100644 index 000000000..641a7b734 --- /dev/null +++ b/src/hooks/use-single-event.ts @@ -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, + }; +} diff --git a/src/services/single-event.ts b/src/services/single-event.ts new file mode 100644 index 000000000..bd7606201 --- /dev/null +++ b/src/services/single-event.ts @@ -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(); + pending = new Map(); + pendingPromises = new Map>(); + + 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(); + 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 = {}; + 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; diff --git a/src/services/user-followers.ts b/src/services/user-followers.ts index a7727eb8b..68319ac42 100644 --- a/src/services/user-followers.ts +++ b/src/services/user-followers.ts @@ -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(); @@ -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); diff --git a/src/views/home/discover-tab.tsx b/src/views/home/discover-tab.tsx index 053a20c5e..4e80977c0 100644 --- a/src/views/home/discover-tab.tsx +++ b/src/views/home/discover-tab.tsx @@ -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, diff --git a/src/views/home/index.tsx b/src/views/home/index.tsx index 0045e8287..82c4f83f4 100644 --- a/src/views/home/index.tsx +++ b/src/views/home/index.tsx @@ -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" }, ]; diff --git a/src/views/home/popular.tsx b/src/views/home/popular.tsx new file mode 100644 index 000000000..e616f3b02 --- /dev/null +++ b/src/views/home/popular.tsx @@ -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[]> = {}; + 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 ( + + + {Array.from(Object.entries(groupedZaps)).map(([eventId, parsedZaps]) => ( + + {parsedZaps.length} + + + ))} + + ); +}