mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-31 16:08:52 +02:00
Add approval button for pending community posts
This commit is contained in:
parent
62a729a805
commit
5f9c96e744
5
.changeset/large-wolves-perform.md
Normal file
5
.changeset/large-wolves-perform.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add approval button for pending community posts
|
@ -20,7 +20,7 @@ export function QuoteRepostButton({
|
||||
|
||||
const handleClick = () => {
|
||||
const nevent = getSharableEventAddress(event);
|
||||
openModal("\nnostr:" + nevent);
|
||||
openModal({ initContent: "\nnostr:" + nevent });
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -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();
|
||||
@ -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<FormValues>("new-note", getValues, setValue, formState);
|
||||
useCacheForm<FormValues>(cacheFormKey, getValues, setValue, formState);
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
@ -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<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]);
|
||||
|
@ -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) : [];
|
||||
|
@ -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>({
|
||||
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
<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>
|
||||
|
@ -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(
|
||||
<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>
|
||||
@ -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 (
|
||||
|
@ -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";
|
||||
|
||||
function PendingPost({ event }: { event: NostrEvent }) {
|
||||
type PendingProps = {
|
||||
event: NostrEvent;
|
||||
community: NostrEvent;
|
||||
};
|
||||
|
||||
function ModPendingPost({ event, community }: PendingProps) {
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
|
||||
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));
|
||||
|
||||
@ -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} />
|
||||
|
@ -23,7 +23,7 @@ export default function StreamShareButton({
|
||||
|
||||
const handleClick = () => {
|
||||
const nevent = getSharableEventAddress(stream.event);
|
||||
openModal("\nnostr:" + nevent);
|
||||
openModal({ initContent: "\nnostr:" + nevent });
|
||||
};
|
||||
|
||||
return (
|
||||
|
Loading…
x
Reference in New Issue
Block a user