Skip to content

Commit

Permalink
Add approval button for pending community posts
Browse files Browse the repository at this point in the history
  • Loading branch information
hzrd149 committed Oct 19, 2023
1 parent 62a729a commit 5f9c96e
Show file tree
Hide file tree
Showing 11 changed files with 144 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/large-wolves-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostrudel": minor
---

Add approval button for pending community posts
2 changes: 1 addition & 1 deletion src/components/note/components/quote-repost-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function QuoteRepostButton({

const handleClick = () => {
const nevent = getSharableEventAddress(event);
openModal("\nnostr:" + nevent);
openModal({ initContent: "\nnostr:" + nevent });
};

return (
Expand Down
14 changes: 11 additions & 3 deletions src/components/post-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModalProps, "children"> & { initContent?: string }) {
initCommunity = "",
}: Omit<ModalProps, "children"> & PostModalProps) {
const toast = useToast();
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
Expand All @@ -73,7 +81,7 @@ export default function PostModal({
content: initContent,
nsfw: false,
nsfwReason: "",
community: "",
community: initCommunity,
split: [] as EventSplit,
},
mode: "all",
Expand All @@ -84,7 +92,7 @@ export default function PostModal({
watch("split");

// cache form to localStorage
useCacheForm<FormValues>("new-note", getValues, setValue, formState);
useCacheForm<FormValues>(cacheFormKey, getValues, setValue, formState);

const textAreaRef = useRef<RefType | null>(null);
const imageUploadRef = useRef<HTMLInputElement | null>(null);
Expand Down
8 changes: 6 additions & 2 deletions src/helpers/nostr/communities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -48,10 +52,10 @@ export function validateCommunity(community: NostrEvent) {
}
}

export function buildApprovalMap(events: Iterable<NostrEvent>) {
export function buildApprovalMap(events: Iterable<NostrEvent>, mods: string[]) {
const approvals = new Map<string, NostrEvent[]>();
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]);
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/use-subscribed-communities-list.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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) : [];
Expand Down
20 changes: 13 additions & 7 deletions src/providers/post-modal-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<PostModalContextType>({
Expand All @@ -13,20 +13,26 @@ export const PostModalContext = React.createContext<PostModalContextType>({

export default function PostModalProvider({ children }: PropsWithChildren) {
const { isOpen, onOpen, onClose } = useDisclosure();
const [initContent, setInitContent] = useState("");
const [initProps, setInitProps] = useState<PostModalProps>({});

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 (
<PostModalContext.Provider value={context}>
<ErrorBoundary>
{isOpen && <PostModal isOpen={isOpen} onClose={onClose} initContent={initContent} />}
{isOpen && <PostModal {...initProps} isOpen={isOpen} onClose={closeModal} />}
{children}
</ErrorBoundary>
</PostModalContext.Provider>
Expand Down
2 changes: 1 addition & 1 deletion src/views/communities/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<VerticalPageLayout>
Expand Down
19 changes: 17 additions & 2 deletions src/views/community/community-home.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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)}`;
Expand All @@ -31,14 +34,16 @@ 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 });

const communityRelays = getCommunityRelays(community);
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";
Expand Down Expand Up @@ -73,6 +78,16 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
<Flex gap="4" alignItems="flex-start" overflow="hidden">
<Flex direction="column" gap="4" flex={1} overflow="hidden">
<ButtonGroup size="sm">
<Button
colorScheme="primary"
leftIcon={<WritingIcon />}
onClick={() =>
openModal({ cacheFormKey: communityCoordinate + "-new-post", initCommunity: communityCoordinate })
}
>
New Post
</Button>
<Divider orientation="vertical" h="2rem" />
<Button leftIcon={<TrendUp01 />} isDisabled>
Trending
</Button>
Expand Down
7 changes: 3 additions & 4 deletions src/views/community/views/newest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,7 +60,7 @@ const ApprovedEvent = memo(
<CardHeader px="2" pt="4" pb="0">
<Heading size="md">
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick}>
{event.content.match(/^[^\n\t]+/)}
{getPostSubject(event)}
</HoverLinkOverlay>
</Heading>
</CardHeader>
Expand Down Expand Up @@ -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 (
Expand Down
90 changes: 82 additions & 8 deletions src/views/community/views/pending.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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 (
<Flex direction="column" gap="2" ref={ref}>
<EmbedEvent event={event} />
<Flex gap="2">
<Button
colorScheme="primary"
leftIcon={<CheckIcon />}
size="sm"
ml="auto"
onClick={approve}
isLoading={loading}
>
Approve
</Button>
</Flex>
</Flex>
);
}

function PendingPost({ event }: PendingProps) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));

Expand All @@ -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 (
<>
<IntersectionObserverProvider callback={callback}>
{pending.map((event) => (
<PendingPost key={getEventUID(event)} event={event} />
<PostComponent key={getEventUID(event)} event={event} community={community} />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} />
Expand Down
2 changes: 1 addition & 1 deletion src/views/streams/components/stream-share-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function StreamShareButton({

const handleClick = () => {
const nevent = getSharableEventAddress(stream.event);
openModal("\nnostr:" + nevent);
openModal({ initContent: "\nnostr:" + nevent });
};

return (
Expand Down

0 comments on commit 5f9c96e

Please sign in to comment.