diff --git a/src/app.tsx b/src/app.tsx
index 7adc384a6..88c6e862a 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -63,6 +63,7 @@ import CommunityFindByNameView from "./views/community/find-by-name";
import CommunityView from "./views/community/index";
import CommunityPendingView from "./views/community/views/pending";
import CommunityNewestView from "./views/community/views/newest";
+import CommunityTrendingView from "./views/community/views/trending";
import RelaysView from "./views/relays";
import RelayView from "./views/relays/relay";
@@ -253,6 +254,8 @@ const router = createHashRouter([
element: ,
children: [
{ path: "", element: },
+ { path: "trending", element: },
+ { path: "newest", element: },
{ path: "pending", element: },
],
},
diff --git a/src/helpers/nostr/communities.ts b/src/helpers/nostr/communities.ts
index 9ba4b22e6..1eca8c1a7 100644
--- a/src/helpers/nostr/communities.ts
+++ b/src/helpers/nostr/communities.ts
@@ -1,6 +1,7 @@
import { validateEvent } from "nostr-tools";
import { NostrEvent, isDTag, isETag, isPTag } from "../../types/nostr-event";
import { getMatchLink, getMatchNostrLink } from "../regexp";
+import { ReactionGroup } from "./reactions";
export const SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER = "communities";
export const COMMUNITY_DEFINITION_KIND = 34550;
@@ -81,3 +82,10 @@ export function buildApprovalMap(events: Iterable, mods: string[]) {
}
return approvals;
}
+
+export function getCommunityPostVote(grouped: ReactionGroup[]) {
+ const up = grouped.find((r) => r.emoji === "+");
+ const down = grouped.find((r) => r.emoji === "-");
+ const vote = (up?.pubkeys.length ?? 0) - (down?.pubkeys.length ?? 0);
+ return { up, down, vote };
+}
diff --git a/src/hooks/use-events-reactions.ts b/src/hooks/use-events-reactions.ts
new file mode 100644
index 000000000..7e0dd782a
--- /dev/null
+++ b/src/hooks/use-events-reactions.ts
@@ -0,0 +1,41 @@
+import { useEffect, useMemo, useState } from "react";
+import eventReactionsService from "../services/event-reactions";
+import { useReadRelayUrls } from "./use-client-relays";
+import { NostrEvent } from "../types/nostr-event";
+import Subject from "../classes/subject";
+
+export default function useEventsReactions(eventIds: string[], additionalRelays: string[] = [], alwaysRequest = true) {
+ const relays = useReadRelayUrls(additionalRelays);
+
+ // get subjects
+ const subjects = useMemo(() => {
+ const dir: Record> = {};
+ for (const eventId of eventIds) {
+ dir[eventId] = eventReactionsService.requestReactions(eventId, relays, alwaysRequest);
+ }
+ return dir;
+ }, [eventIds, relays.join("|"), alwaysRequest]);
+
+ // get values out of subjects
+ const reactions: Record = {};
+ for (const [id, subject] of Object.entries(subjects)) {
+ if (subject.value) reactions[id] = subject.value;
+ }
+
+ const [_, update] = useState(0);
+
+ // subscribe to subjects
+ useEffect(() => {
+ const listener = () => update((v) => v + 1);
+ for (const [_, sub] of Object.entries(subjects)) {
+ sub?.subscribe(listener, undefined, false);
+ }
+ return () => {
+ for (const [_, sub] of Object.entries(subjects)) {
+ sub?.unsubscribe(listener, undefined);
+ }
+ };
+ }, [subjects, update]);
+
+ return reactions;
+}
diff --git a/src/views/community/community-home.tsx b/src/views/community/community-home.tsx
index c510d3e75..d82c1a4f7 100644
--- a/src/views/community/community-home.tsx
+++ b/src/views/community/community-home.tsx
@@ -48,8 +48,10 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
"#a": [communityCoordinate],
});
- let active = "new";
+ let active = "newest";
+ if (location.pathname.endsWith("/newest")) active = "newest";
if (location.pathname.endsWith("/pending")) active = "pending";
+ if (location.pathname.endsWith("/trending")) active = "trending";
return (
<>
@@ -97,15 +99,21 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
New Post
- } isDisabled>
+ }
+ as={RouterLink}
+ to={getCommunityPath(community) + "/trending"}
+ colorScheme={active === "trending" ? "primary" : "gray"}
+ replace
+ >
Trending
}
as={RouterLink}
- to={getCommunityPath(community)}
+ to={getCommunityPath(community) + "/newest"}
+ colorScheme={active === "newest" ? "primary" : "gray"}
replace
- colorScheme={active === "new" ? "primary" : "gray"}
>
New
@@ -113,8 +121,8 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
leftIcon={}
as={RouterLink}
to={getCommunityPath(community) + "/pending"}
- replace
colorScheme={active == "pending" ? "primary" : "gray"}
+ replace
>
Pending
diff --git a/src/views/community/components/post-vote-buttions.tsx b/src/views/community/components/post-vote-buttions.tsx
index ae499f3fc..9715a2d12 100644
--- a/src/views/community/components/post-vote-buttions.tsx
+++ b/src/views/community/components/post-vote-buttions.tsx
@@ -6,7 +6,7 @@ import useEventReactions from "../../../hooks/use-event-reactions";
import { useSigningContext } from "../../../providers/signing-provider";
import { draftEventReaction, groupReactions } from "../../../helpers/nostr/reactions";
import clientRelaysService from "../../../services/client-relays";
-import { getCommunityRelays } from "../../../helpers/nostr/communities";
+import { getCommunityPostVote, getCommunityRelays } from "../../../helpers/nostr/communities";
import { unique } from "../../../helpers/array";
import eventReactionsService from "../../../services/event-reactions";
import NostrPublishAction from "../../../classes/nostr-publish-action";
@@ -23,9 +23,7 @@ export default function PostVoteButtons({
const toast = useToast();
const grouped = useMemo(() => groupReactions(reactions ?? []), [reactions]);
- const up = grouped.find((r) => r.emoji === "+");
- const down = grouped.find((r) => r.emoji === "-");
- const vote = (up?.pubkeys.length ?? 0) - (down?.pubkeys.length ?? 0);
+ const { vote, up, down } = getCommunityPostVote(grouped);
const hasUpVote = !!account && !!up?.pubkeys.includes(account.pubkey);
const hasDownVote = !!account && !!down?.pubkeys.includes(account.pubkey);
diff --git a/src/views/community/views/trending.tsx b/src/views/community/views/trending.tsx
new file mode 100644
index 000000000..e7ee43b56
--- /dev/null
+++ b/src/views/community/views/trending.tsx
@@ -0,0 +1,76 @@
+import { memo, useMemo } from "react";
+import { Flex } from "@chakra-ui/react";
+import { useOutletContext } from "react-router-dom";
+
+import {
+ buildApprovalMap,
+ getCommunityMods,
+ getCommunityPostVote,
+ getCommunityRelays,
+} from "../../../helpers/nostr/communities";
+import useSubject from "../../../hooks/use-subject";
+import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
+import { NostrEvent } from "../../../types/nostr-event";
+import IntersectionObserverProvider from "../../../providers/intersection-observer";
+import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status";
+import PostVoteButtons from "../components/post-vote-buttions";
+import TimelineLoader from "../../../classes/timeline-loader";
+import CommunityPost from "../components/community-post";
+import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
+import useEventsReactions from "../../../hooks/use-events-reactions";
+import { groupReactions } from "../../../helpers/nostr/reactions";
+
+const ApprovedEvent = memo(
+ ({ event, approvals, community }: { event: NostrEvent; approvals: NostrEvent[]; community: NostrEvent }) => {
+ return (
+
+
+
+
+ );
+ },
+);
+
+export default function CommunityTrendingView() {
+ const { community, timeline } = useOutletContext() as { community: NostrEvent; timeline: TimelineLoader };
+ const muteFilter = useUserMuteFilter();
+ const mods = getCommunityMods(community);
+
+ const events = useSubject(timeline.timeline);
+ const approvalMap = buildApprovalMap(events, mods);
+
+ const approved = events
+ .filter((e) => approvalMap.has(e.id))
+ .map((event) => ({ event, approvals: approvalMap.get(event.id) }))
+ .filter((e) => !muteFilter(e.event));
+
+ // fetch votes for approved posts
+ const eventReactions = useEventsReactions(
+ approved.map((e) => e.event.id),
+ getCommunityRelays(community),
+ );
+ const eventVotes = useMemo(() => {
+ const dir: Record = {};
+ for (const [id, reactions] of Object.entries(eventReactions)) {
+ const grouped = groupReactions(reactions);
+ const { vote } = getCommunityPostVote(grouped);
+ dir[id] = vote;
+ }
+ return dir;
+ }, [eventReactions]);
+
+ const sorted = approved.sort((a, b) => (eventVotes[b.event.id] ?? 0) - (eventVotes[a.event.id] ?? 0));
+
+ const callback = useTimelineCurserIntersectionCallback(timeline);
+
+ return (
+ <>
+
+ {sorted.map(({ event, approvals }) => (
+
+ ))}
+
+
+ >
+ );
+}