From 5f9c96e744aa8f5086c6f76a2c2148a322c9af92 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Thu, 19 Oct 2023 14:05:01 -0500 Subject: [PATCH] Add approval button for pending community posts --- .changeset/large-wolves-perform.md | 5 ++ .../note/components/quote-repost-button.tsx | 2 +- src/components/post-modal/index.tsx | 14 ++- src/helpers/nostr/communities.ts | 8 +- src/hooks/use-subscribed-communities-list.ts | 5 +- src/providers/post-modal-provider.tsx | 20 +++-- src/views/communities/index.tsx | 2 +- src/views/community/community-home.tsx | 19 +++- src/views/community/views/newest.tsx | 7 +- src/views/community/views/pending.tsx | 90 +++++++++++++++++-- .../components/stream-share-button.tsx | 2 +- 11 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 .changeset/large-wolves-perform.md diff --git a/.changeset/large-wolves-perform.md b/.changeset/large-wolves-perform.md new file mode 100644 index 000000000..631e95da8 --- /dev/null +++ b/.changeset/large-wolves-perform.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add approval button for pending community posts diff --git a/src/components/note/components/quote-repost-button.tsx b/src/components/note/components/quote-repost-button.tsx index 9a3ba1372..71b2d4cea 100644 --- a/src/components/note/components/quote-repost-button.tsx +++ b/src/components/note/components/quote-repost-button.tsx @@ -20,7 +20,7 @@ export function QuoteRepostButton({ const handleClick = () => { const nevent = getSharableEventAddress(event); - openModal("\nnostr:" + nevent); + openModal({ initContent: "\nnostr:" + nevent }); }; return ( diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index 7b485405e..6dcaf89d3 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -55,11 +55,19 @@ type FormValues = { split: EventSplit; }; +export type PostModalProps = { + cacheFormKey?: string; + initContent?: string; + initCommunity?: string; +}; + export default function PostModal({ isOpen, onClose, + cacheFormKey = "new-note", initContent = "", -}: Omit & { initContent?: string }) { + initCommunity = "", +}: Omit & PostModalProps) { const toast = useToast(); const account = useCurrentAccount()!; const { requestSignature } = useSigningContext(); @@ -73,7 +81,7 @@ export default function PostModal({ content: initContent, nsfw: false, nsfwReason: "", - community: "", + community: initCommunity, split: [] as EventSplit, }, mode: "all", @@ -84,7 +92,7 @@ export default function PostModal({ watch("split"); // cache form to localStorage - useCacheForm("new-note", getValues, setValue, formState); + useCacheForm(cacheFormKey, getValues, setValue, formState); const textAreaRef = useRef(null); const imageUploadRef = useRef(null); diff --git a/src/helpers/nostr/communities.ts b/src/helpers/nostr/communities.ts index 4013bdcb2..cc4813a9d 100644 --- a/src/helpers/nostr/communities.ts +++ b/src/helpers/nostr/communities.ts @@ -29,6 +29,10 @@ export function getCommunityRules(community: NostrEvent) { return community.tags.find((t) => t[0] === "rules")?.[1]; } +export function getPostSubject(event: NostrEvent) { + return event.tags.find((t) => t[0] === "subject")?.[1] || event.content.match(/^[^\n\t]+/); +} + export function getApprovedEmbeddedNote(approval: NostrEvent) { if (!approval.content) return null; try { @@ -48,10 +52,10 @@ export function validateCommunity(community: NostrEvent) { } } -export function buildApprovalMap(events: Iterable) { +export function buildApprovalMap(events: Iterable, mods: string[]) { const approvals = new Map(); for (const event of events) { - if (event.kind === COMMUNITY_APPROVAL_KIND) { + if (event.kind === COMMUNITY_APPROVAL_KIND && mods.includes(event.pubkey)) { for (const tag of event.tags) { if (isETag(tag)) { const arr = approvals.get(tag[1]); diff --git a/src/hooks/use-subscribed-communities-list.ts b/src/hooks/use-subscribed-communities-list.ts index 686a88a62..50bc05dc7 100644 --- a/src/hooks/use-subscribed-communities-list.ts +++ b/src/hooks/use-subscribed-communities-list.ts @@ -1,9 +1,10 @@ import { COMMUNITY_DEFINITION_KIND, SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER } from "../helpers/nostr/communities"; import { NOTE_LIST_KIND, getParsedCordsFromList } from "../helpers/nostr/lists"; +import { RequestOptions } from "../services/replaceable-event-requester"; import { useCurrentAccount } from "./use-current-account"; import useReplaceableEvent from "./use-replaceable-event"; -export default function useSubscribedCommunitiesList(pubkey?: string) { +export default function useSubscribedCommunitiesList(pubkey?: string, opts?: RequestOptions) { const account = useCurrentAccount(); const key = pubkey ?? account?.pubkey; @@ -15,6 +16,8 @@ export default function useSubscribedCommunitiesList(pubkey?: string) { pubkey: key, } : undefined, + [], + opts, ); const pointers = list ? getParsedCordsFromList(list).filter((cord) => cord.kind === COMMUNITY_DEFINITION_KIND) : []; diff --git a/src/providers/post-modal-provider.tsx b/src/providers/post-modal-provider.tsx index fd1ae6bcb..75e206caa 100644 --- a/src/providers/post-modal-provider.tsx +++ b/src/providers/post-modal-provider.tsx @@ -1,10 +1,10 @@ import React, { PropsWithChildren, useCallback, useMemo, useState } from "react"; import { useDisclosure } from "@chakra-ui/react"; import { ErrorBoundary } from "../components/error-boundary"; -import PostModal from "../components/post-modal"; +import PostModal, { PostModalProps } from "../components/post-modal"; export type PostModalContextType = { - openModal: (content?: string) => void; + openModal: (props?: PostModalProps) => void; }; export const PostModalContext = React.createContext({ @@ -13,20 +13,26 @@ export const PostModalContext = React.createContext({ export default function PostModalProvider({ children }: PropsWithChildren) { const { isOpen, onOpen, onClose } = useDisclosure(); - const [initContent, setInitContent] = useState(""); + const [initProps, setInitProps] = useState({}); + const openModal = useCallback( - (content?: string) => { - if (content) setInitContent(content); + (props?: PostModalProps) => { + setInitProps(props ?? {}); onOpen(); }, - [onOpen, setInitContent], + [onOpen, setInitProps], ); + const closeModal = useCallback(() => { + setInitProps({}); + onClose(); + }, [onOpen, setInitProps]); + const context = useMemo(() => ({ openModal }), [openModal]); return ( - {isOpen && } + {isOpen && } {children} diff --git a/src/views/communities/index.tsx b/src/views/communities/index.tsx index c737c7a33..4a9622dbd 100644 --- a/src/views/communities/index.tsx +++ b/src/views/communities/index.tsx @@ -20,7 +20,7 @@ function LoadCommunityCard({ pointer }: { pointer: AddressPointer }) { function CommunitiesHomePage() { const account = useCurrentAccount()!; - const { pointers: communities } = useSubscribedCommunitiesList(account.pubkey); + const { pointers: communities } = useSubscribedCommunitiesList(account.pubkey, { alwaysRequest: true }); return ( diff --git a/src/views/community/community-home.tsx b/src/views/community/community-home.tsx index d8acd341b..9f7f6711f 100644 --- a/src/views/community/community-home.tsx +++ b/src/views/community/community-home.tsx @@ -1,4 +1,4 @@ -import { Button, ButtonGroup, Flex, Heading, Text } from "@chakra-ui/react"; +import { Button, ButtonGroup, Divider, Flex, Heading, Text } from "@chakra-ui/react"; import { Outlet, Link as RouterLink, useLocation } from "react-router-dom"; import { Kind, nip19 } from "nostr-tools"; @@ -23,6 +23,9 @@ import HorizontalCommunityDetails from "./components/horizonal-community-details import { useReadRelayUrls } from "../../hooks/use-client-relays"; import useTimelineLoader from "../../hooks/use-timeline-loader"; import { getEventCoordinate, getEventUID } from "../../helpers/nostr/events"; +import { WritingIcon } from "../../components/icons"; +import { useContext } from "react"; +import { PostModalContext } from "../../providers/post-modal-provider"; function getCommunityPath(community: NostrEvent) { return `/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`; @@ -31,6 +34,8 @@ function getCommunityPath(community: NostrEvent) { export default function CommunityHomePage({ community }: { community: NostrEvent }) { const image = getCommunityImage(community); const location = useLocation(); + const { openModal } = useContext(PostModalContext); + const communityCoordinate = getEventCoordinate(community); const verticalLayout = useBreakpointValue({ base: true, xl: false }); @@ -38,7 +43,7 @@ export default function CommunityHomePage({ community }: { community: NostrEvent const readRelays = useReadRelayUrls(communityRelays); const timeline = useTimelineLoader(`${getEventUID(community)}-timeline`, readRelays, { kinds: [Kind.Text, COMMUNITY_APPROVAL_KIND], - "#a": [getEventCoordinate(community)], + "#a": [communityCoordinate], }); let active = "new"; @@ -73,6 +78,16 @@ export default function CommunityHomePage({ community }: { community: NostrEvent + + diff --git a/src/views/community/views/newest.tsx b/src/views/community/views/newest.tsx index f09dbe5c1..0f35e735b 100644 --- a/src/views/community/views/newest.tsx +++ b/src/views/community/views/newest.tsx @@ -3,7 +3,7 @@ import { AvatarGroup, Card, CardBody, CardFooter, CardHeader, Flex, Heading, Lin import { useOutletContext, Link as RouterLink } from "react-router-dom"; import dayjs from "dayjs"; -import { COMMUNITY_APPROVAL_KIND, buildApprovalMap, getCommunityMods } from "../../../helpers/nostr/communities"; +import { buildApprovalMap, getCommunityMods, getPostSubject } from "../../../helpers/nostr/communities"; import { getEventUID } from "../../../helpers/nostr/events"; import useSubject from "../../../hooks/use-subject"; import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; @@ -60,7 +60,7 @@ const ApprovedEvent = memo( - {event.content.match(/^[^\n\t]+/)} + {getPostSubject(event)} @@ -92,13 +92,12 @@ export default function CommunityNewestView() { const mods = getCommunityMods(community); const events = useSubject(timeline.timeline); - const approvalMap = buildApprovalMap(events); + const approvalMap = buildApprovalMap(events, mods); const approved = events .filter((e) => approvalMap.has(e.id)) .map((event) => ({ event, approvals: approvalMap.get(event.id) })); - const approvals = events.filter((e) => e.kind === COMMUNITY_APPROVAL_KIND && mods.includes(e.pubkey)); const callback = useTimelineCurserIntersectionCallback(timeline); return ( diff --git a/src/views/community/views/pending.tsx b/src/views/community/views/pending.tsx index 647bf157b..4a2e71c08 100644 --- a/src/views/community/views/pending.tsx +++ b/src/views/community/views/pending.tsx @@ -1,18 +1,87 @@ -import { useRef } from "react"; -import { Box } from "@chakra-ui/react"; +import { useCallback, useRef, useState } from "react"; +import { Box, Button, Flex, useToast } from "@chakra-ui/react"; import { useOutletContext } from "react-router-dom"; +import dayjs from "dayjs"; -import { NostrEvent } from "../../../types/nostr-event"; -import { getEventUID } from "../../../helpers/nostr/events"; -import { COMMUNITY_APPROVAL_KIND, buildApprovalMap } from "../../../helpers/nostr/communities"; +import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; +import { getEventCoordinate, getEventUID } from "../../../helpers/nostr/events"; +import { + COMMUNITY_APPROVAL_KIND, + buildApprovalMap, + getCommunityMods, + getCommunityRelays, +} from "../../../helpers/nostr/communities"; import useSubject from "../../../hooks/use-subject"; import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { EmbedEvent } from "../../../components/embed-event"; import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status"; import TimelineLoader from "../../../classes/timeline-loader"; +import { CheckIcon } from "../../../components/icons"; +import { useSigningContext } from "../../../providers/signing-provider"; +import { useCurrentAccount } from "../../../hooks/use-current-account"; +import NostrPublishAction from "../../../classes/nostr-publish-action"; +import { useWriteRelayUrls } from "../../../hooks/use-client-relays"; + +type PendingProps = { + event: NostrEvent; + community: NostrEvent; +}; + +function ModPendingPost({ event, community }: PendingProps) { + const toast = useToast(); + const { requestSignature } = useSigningContext(); -function PendingPost({ event }: { event: NostrEvent }) { + const ref = useRef(null); + useRegisterIntersectionEntity(ref, getEventUID(event)); + + const communityRelays = getCommunityRelays(community); + const writeRelays = useWriteRelayUrls(communityRelays); + const [loading, setLoading] = useState(false); + const approve = useCallback(async () => { + setLoading(true); + try { + const relay = communityRelays[0]; + const draft: DraftNostrEvent = { + kind: COMMUNITY_APPROVAL_KIND, + content: JSON.stringify(event), + created_at: dayjs().unix(), + tags: [ + relay ? ["a", getEventCoordinate(community), relay] : ["a", getEventCoordinate(community)], + ["e", event.id], + ["p", event.pubkey], + ["k", String(event.kind)], + ], + }; + + const signed = await requestSignature(draft); + new NostrPublishAction("Approve", writeRelays, signed); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + setLoading(false); + }, [event, requestSignature, writeRelays, setLoading, community]); + + return ( + + + + + + + ); +} + +function PendingPost({ event }: PendingProps) { const ref = useRef(null); useRegisterIntersectionEntity(ref, getEventUID(event)); @@ -24,20 +93,25 @@ function PendingPost({ event }: { event: NostrEvent }) { } export default function CommunityPendingView() { + const account = useCurrentAccount(); const { community, timeline } = useOutletContext() as { community: NostrEvent; timeline: TimelineLoader }; const events = useSubject(timeline.timeline); - const approvals = buildApprovalMap(events); + const mods = getCommunityMods(community); + const approvals = buildApprovalMap(events, mods); const pending = events.filter((e) => e.kind !== COMMUNITY_APPROVAL_KIND && !approvals.has(e.id)); const callback = useTimelineCurserIntersectionCallback(timeline); + const isMod = !!account && mods.includes(account?.pubkey); + const PostComponent = isMod ? ModPendingPost : PendingPost; + return ( <> {pending.map((event) => ( - + ))} diff --git a/src/views/streams/components/stream-share-button.tsx b/src/views/streams/components/stream-share-button.tsx index 281aa09f0..d7ff17bad 100644 --- a/src/views/streams/components/stream-share-button.tsx +++ b/src/views/streams/components/stream-share-button.tsx @@ -23,7 +23,7 @@ export default function StreamShareButton({ const handleClick = () => { const nevent = getSharableEventAddress(stream.event); - openModal("\nnostr:" + nevent); + openModal({ initContent: "\nnostr:" + nevent }); }; return (