From b150aac326966a00916cb40ae9b070c9dc083d55 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 25 Oct 2023 10:33:37 -0500 Subject: [PATCH] add community trending view --- src/app.tsx | 3 + src/helpers/nostr/communities.ts | 8 ++ src/hooks/use-events-reactions.ts | 41 ++++++++++ src/views/community/community-home.tsx | 18 +++-- .../components/post-vote-buttions.tsx | 6 +- src/views/community/views/trending.tsx | 76 +++++++++++++++++++ 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 src/hooks/use-events-reactions.ts create mode 100644 src/views/community/views/trending.tsx 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 - @@ -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 }) => ( + + ))} + + + + ); +}