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) => {
- }>
- Search
-
}>
Broadcast
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}
+
+
+ ))}
+
+ );
+}