Add community create and edit modals

This commit is contained in:
hzrd149 2023-10-23 13:01:23 -05:00
parent 6c276147aa
commit 28de4d4704
9 changed files with 432 additions and 103 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add community create and edit modals

View File

@ -29,6 +29,9 @@ export function getCommunityDescription(community: NostrEvent) {
export function getCommunityRules(community: NostrEvent) {
return community.tags.find((t) => t[0] === "rules")?.[1];
}
export function getCommunityRanking(community: NostrEvent) {
return community.tags.find((t) => t[0] === "rank_mode")?.[1];
}
export function getPostSubject(event: NostrEvent) {
const subject = event.tags.find((t) => t[0] === "subject")?.[1];

View File

@ -0,0 +1,150 @@
import {
Button,
Flex,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
Radio,
RadioGroup,
Stack,
Textarea,
} from "@chakra-ui/react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import UserAvatar from "../../../components/user-avatar";
import { UserLink } from "../../../components/user-link";
export type FormValues = {
name: string;
banner: string;
description: string;
rules: string;
mods: string[];
relays: string[];
ranking: string;
};
export default function CommunityCreateModal({
isOpen,
onClose,
onSubmit,
defaultValues,
isUpdate,
...props
}: Omit<ModalProps, "children"> & {
onSubmit: SubmitHandler<FormValues>;
defaultValues?: FormValues;
isUpdate?: boolean;
}) {
const account = useCurrentAccount();
const {
register,
formState: { errors },
handleSubmit,
watch,
getValues,
setValue,
} = useForm<FormValues>({
mode: "all",
defaultValues: defaultValues || {
name: "",
banner: "",
description: "",
rules: "",
mods: account ? [account.pubkey] : [],
relays: [],
ranking: "votes",
},
});
watch("mods");
watch("ranking");
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl" {...props}>
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
<ModalHeader p="4">{isUpdate ? "Update Community" : "Create Community"}</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0" gap="4" display="flex" flexDirection="column">
{!isUpdate && (
<FormControl isInvalid={!!errors.name}>
<FormLabel>Community Name</FormLabel>
<Input
type="text"
{...register("name", {
required: true,
validate: (v) => {
if (/\p{Z}/iu.test(v)) return "Must not have spaces";
return true;
},
})}
isReadOnly={isUpdate}
autoComplete="off"
/>
<FormHelperText>The name of your community (no-spaces)</FormHelperText>
{errors.name?.message && <FormErrorMessage>{errors.name?.message}</FormErrorMessage>}
</FormControl>
)}
<FormControl isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} autoComplete="off" />
<FormHelperText>short description about your community</FormHelperText>
{errors.description?.message && <FormErrorMessage>{errors.description?.message}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={!!errors.rules}>
<FormLabel>Rules and Guidelines</FormLabel>
<Textarea {...register("rules")} autoComplete="off" />
<FormHelperText>Rules and posting guidelines</FormHelperText>
{errors.rules?.message && <FormErrorMessage>{errors.rules?.message}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={!!errors.mods}>
<FormLabel>Moderators</FormLabel>
{getValues().mods.map((pubkey) => (
<Flex gap="2" alignItems="center" key={pubkey}>
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" />
</Flex>
))}
</FormControl>
<FormControl isInvalid={!!errors.mods}>
<FormLabel>Default Raking</FormLabel>
<RadioGroup
value={getValues().ranking}
onChange={(e) => setValue("ranking", e, { shouldDirty: true, shouldTouch: true })}
>
<Stack direction="row">
<Radio value="votes">Votes</Radio>
<Radio value="zaps">Zaps</Radio>
</Stack>
</RadioGroup>
<FormHelperText>The default by posts are ranked when viewing the community</FormHelperText>
{errors.rules?.message && <FormErrorMessage>{errors.rules?.message}</FormErrorMessage>}
</FormControl>
</ModalBody>
<ModalFooter p="4" display="flex" gap="2">
<Button onClick={onClose}>Cancel</Button>
<Button colorScheme="primary" type="submit">
{isUpdate ? "Update Community" : "Create Community"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -1,44 +1,101 @@
import { Button, Center, Flex, Heading, Link, SimpleGrid, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { Button, Center, Flex, Heading, Link, SimpleGrid, Text, useDisclosure, useToast } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { Navigate } from "react-router-dom";
import dayjs from "dayjs";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { ErrorBoundary } from "../../components/error-boundary";
import useSubscribedCommunitiesList from "../../hooks/use-subscribed-communities-list";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { PointerCommunityCard } from "./components/community-card";
import CommunityCreateModal, { FormValues } from "./components/community-create-modal";
import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent } from "../../types/nostr-event";
import { COMMUNITY_DEFINITION_KIND, getCommunityName } from "../../helpers/nostr/communities";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { unique } from "../../helpers/array";
import clientRelaysService from "../../services/client-relays";
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
function CommunitiesHomePage() {
const toast = useToast();
const { requestSignature } = useSigningContext();
const navigate = useNavigate();
const account = useCurrentAccount()!;
const createModal = useDisclosure();
const { pointers: communities } = useSubscribedCommunitiesList(account.pubkey, { alwaysRequest: true });
const createCommunity = async (values: FormValues) => {
try {
const draft: DraftNostrEvent = {
kind: COMMUNITY_DEFINITION_KIND,
created_at: dayjs().unix(),
content: "",
tags: [["d", values.name]],
};
for (const pubkey of values.mods) {
draft.tags.push(["p", pubkey, "moderator"]);
}
for (const url of values.relays) {
draft.tags.push(["relay", url]);
}
if (values.description) draft.tags.push(["description", values.description]);
if (values.banner) draft.tags.push(["image", values.banner]);
if (values.ranking) draft.tags.push(["rank_mode", values.ranking]);
const signed = await requestSignature(draft);
new NostrPublishAction(
"Create Community",
unique([...clientRelaysService.getWriteUrls(), ...values.relays]),
signed,
);
replaceableEventLoaderService.handleEvent(signed);
navigate(`/c/${getCommunityName(signed)}/${signed.pubkey}`);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
return (
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<Button as={RouterLink} to="/communities/explore">
Explore Communities
</Button>
</Flex>
{communities.length > 0 ? (
<SimpleGrid spacing="2" columns={{ base: 1, lg: 2 }}>
{communities.map((pointer) => (
<ErrorBoundary key={pointer.kind + pointer.pubkey + pointer.identifier}>
<PointerCommunityCard pointer={pointer} />
</ErrorBoundary>
))}
</SimpleGrid>
) : (
<Center aspectRatio={3 / 4} flexDirection="column" gap="4">
<Heading size="md">No communities :(</Heading>
<Text>
go find a cool one to join.{" "}
<Link as={RouterLink} to="/communities/explore" color="blue.500">
Explore
</Link>
</Text>
</Center>
<>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<Button as={RouterLink} to="/communities/explore">
Explore Communities
</Button>
<Button ml="auto" onClick={createModal.onOpen}>
Create Community
</Button>
</Flex>
{communities.length > 0 ? (
<SimpleGrid spacing="2" columns={{ base: 1, lg: 2 }}>
{communities.map((pointer) => (
<ErrorBoundary key={pointer.kind + pointer.pubkey + pointer.identifier}>
<PointerCommunityCard pointer={pointer} />
</ErrorBoundary>
))}
</SimpleGrid>
) : (
<Center aspectRatio={3 / 4} flexDirection="column" gap="4">
<Heading size="md">No communities :(</Heading>
<Text>
go find a cool one to join.{" "}
<Link as={RouterLink} to="/communities/explore" color="blue.500">
Explore
</Link>
</Text>
</Center>
)}
</VerticalPageLayout>
{createModal.isOpen && (
<CommunityCreateModal isOpen={createModal.isOpen} onClose={createModal.onClose} onSubmit={createCommunity} />
)}
</VerticalPageLayout>
</>
);
}

View File

@ -1,4 +1,4 @@
import { Button, ButtonGroup, Divider, Flex, Heading, Text } from "@chakra-ui/react";
import { Button, ButtonGroup, Divider, Flex, Heading, Text, useDisclosure } from "@chakra-ui/react";
import { Outlet, Link as RouterLink, useLocation } from "react-router-dom";
import { Kind, nip19 } from "nostr-tools";
@ -26,6 +26,7 @@ import { getEventCoordinate, getEventUID } from "../../helpers/nostr/events";
import { WritingIcon } from "../../components/icons";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import CommunityEditModal from "./components/community-edit-modal";
function getCommunityPath(community: NostrEvent) {
return `/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`;
@ -35,6 +36,7 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
const image = getCommunityImage(community);
const location = useLocation();
const { openModal } = useContext(PostModalContext);
const editModal = useDisclosure();
const communityCoordinate = getEventCoordinate(community);
const verticalLayout = useBreakpointValue({ base: true, xl: false });
@ -50,77 +52,90 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
if (location.pathname.endsWith("/pending")) active = "pending";
return (
<AdditionalRelayProvider relays={communityRelays}>
<VerticalPageLayout pt={image && "0"}>
<Flex
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
aspectRatio={3 / 1}
backgroundColor="rgba(0,0,0,0.2)"
p="4"
gap="4"
direction="column"
justifyContent="flex-end"
textShadow="2px 2px var(--chakra-blur-sm) var(--chakra-colors-blackAlpha-800)"
>
<Heading>{getCommunityName(community)}</Heading>
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={community.pubkey} size="sm" />
<Text>by</Text>
<UserLink pubkey={community.pubkey} />
</Flex>
</Flex>
{verticalLayout && <HorizontalCommunityDetails community={community} w="full" flexShrink={0} />}
<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,
requireSubject: true,
})
}
>
New Post
</Button>
<Divider orientation="vertical" h="2rem" />
<Button leftIcon={<TrendUp01 />} isDisabled>
Trending
</Button>
<Button
leftIcon={<Clock />}
as={RouterLink}
to={getCommunityPath(community)}
replace
colorScheme={active === "new" ? "primary" : "gray"}
>
New
</Button>
<Button
leftIcon={<Hourglass03 />}
as={RouterLink}
to={getCommunityPath(community) + "/pending"}
replace
colorScheme={active == "pending" ? "primary" : "gray"}
>
Pending
</Button>
</ButtonGroup>
<Outlet context={{ community, timeline }} />
<>
<AdditionalRelayProvider relays={communityRelays}>
<VerticalPageLayout pt={image && "0"}>
<Flex
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
aspectRatio={3 / 1}
backgroundColor="rgba(0,0,0,0.2)"
p="4"
gap="4"
direction="column"
justifyContent="flex-end"
textShadow="2px 2px var(--chakra-blur-sm) var(--chakra-colors-blackAlpha-800)"
>
<Heading>{getCommunityName(community)}</Heading>
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={community.pubkey} size="sm" />
<Text>by</Text>
<UserLink pubkey={community.pubkey} />
</Flex>
</Flex>
{!verticalLayout && <VerticalCommunityDetails community={community} w="full" maxW="xs" flexShrink={0} />}
</Flex>
</VerticalPageLayout>
</AdditionalRelayProvider>
{verticalLayout && (
<HorizontalCommunityDetails community={community} w="full" flexShrink={0} onEditClick={editModal.onOpen} />
)}
<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,
requireSubject: true,
})
}
>
New Post
</Button>
<Divider orientation="vertical" h="2rem" />
<Button leftIcon={<TrendUp01 />} isDisabled>
Trending
</Button>
<Button
leftIcon={<Clock />}
as={RouterLink}
to={getCommunityPath(community)}
replace
colorScheme={active === "new" ? "primary" : "gray"}
>
New
</Button>
<Button
leftIcon={<Hourglass03 />}
as={RouterLink}
to={getCommunityPath(community) + "/pending"}
replace
colorScheme={active == "pending" ? "primary" : "gray"}
>
Pending
</Button>
</ButtonGroup>
<Outlet context={{ community, timeline }} />
</Flex>
{!verticalLayout && (
<VerticalCommunityDetails
community={community}
w="full"
maxW="xs"
flexShrink={0}
onEditClick={editModal.onOpen}
/>
)}
</Flex>
</VerticalPageLayout>
</AdditionalRelayProvider>
{editModal && <CommunityEditModal isOpen={editModal.isOpen} onClose={editModal.onClose} community={community} />}
</>
);
}

View File

@ -0,0 +1,88 @@
import { useMemo } from "react";
import { ModalProps, useDisclosure, useToast } from "@chakra-ui/react";
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { useSigningContext } from "../../../providers/signing-provider";
import {
COMMUNITY_DEFINITION_KIND,
getCommunityDescription,
getCommunityImage,
getCommunityMods,
getCommunityName,
getCommunityRanking,
getCommunityRelays,
getCommunityRules,
} from "../../../helpers/nostr/communities";
import CommunityCreateModal, { FormValues } from "../../communities/components/community-create-modal";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
import { unique } from "../../../helpers/array";
import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
export default function CommunityEditModal({
isOpen,
onClose,
community,
...props
}: Omit<ModalProps, "children"> & { community: NostrEvent }) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const defaultValues = useMemo<FormValues>(
() => ({
name: getCommunityName(community),
description: getCommunityDescription(community) || "",
banner: getCommunityImage(community) || "",
rules: getCommunityRules(community) || "",
mods: getCommunityMods(community) || [],
relays: getCommunityRelays(community) || [],
ranking: getCommunityRanking(community) || "votes",
}),
[community],
);
const updateCommunity = async (values: FormValues) => {
try {
const draft: DraftNostrEvent = {
kind: COMMUNITY_DEFINITION_KIND,
created_at: dayjs().unix(),
content: "",
tags: [["d", getCommunityName(community)]],
};
for (const pubkey of values.mods) {
draft.tags.push(["p", pubkey, "moderator"]);
}
for (const url of values.relays) {
draft.tags.push(["relay", url]);
}
if (values.description) draft.tags.push(["description", values.description]);
if (values.banner) draft.tags.push(["image", values.banner]);
if (values.ranking) draft.tags.push(["rank_mode", values.ranking]);
const signed = await requestSignature(draft);
new NostrPublishAction(
"Update Community",
unique([...clientRelaysService.getWriteUrls(), ...values.relays]),
signed,
);
replaceableEventLoaderService.handleEvent(signed);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
return (
<CommunityCreateModal
isOpen={isOpen}
onClose={onClose}
onSubmit={updateCommunity}
defaultValues={defaultValues}
isUpdate
{...props}
/>
);
}

View File

@ -7,11 +7,15 @@ import { CodeIcon, ExternalLinkIcon, RepostIcon } from "../../../components/icon
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { getSharableEventAddress } from "../../../helpers/nip19";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import PencilLine from "../../../components/icons/pencil-line";
export default function CommunityMenu({
community,
onEditClick,
...props
}: Omit<MenuIconButtonProps, "children"> & { community: NostrEvent }) {
}: Omit<MenuIconButtonProps, "children"> & { community: NostrEvent; onEditClick?: () => void }) {
const account = useCurrentAccount();
const debugModal = useDisclosure();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
@ -25,6 +29,11 @@ export default function CommunityMenu({
View in app...
</MenuItem>
)}
{account?.pubkey === community.pubkey && onEditClick && (
<MenuItem onClick={onEditClick} icon={<PencilLine />}>
Edit Community
</MenuItem>
)}
<MenuItem onClick={() => copyToClipboard("nostr:" + address)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>

View File

@ -30,8 +30,9 @@ import CommunityMembersModal from "./community-members-modal";
export default function HorizontalCommunityDetails({
community,
onEditClick,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
}: Omit<CardProps, "children"> & { community: NostrEvent; onEditClick?: () => void }) {
const membersModal = useDisclosure();
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
@ -47,7 +48,7 @@ export default function HorizontalCommunityDetails({
<CardBody>
<ButtonGroup float="right">
<CommunityJoinButton community={community} />
<CommunityMenu community={community} aria-label="More" />
<CommunityMenu community={community} aria-label="More" onEditClick={onEditClick} />
</ButtonGroup>
{description && (
<>

View File

@ -18,8 +18,9 @@ import { readablizeSats } from "../../../helpers/bolt11";
export default function VerticalCommunityDetails({
community,
onEditClick,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
}: Omit<CardProps, "children"> & { community: NostrEvent; onEditClick?: () => void }) {
const membersModal = useDisclosure();
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
@ -41,7 +42,7 @@ export default function VerticalCommunityDetails({
)}
<ButtonGroup w="full">
<CommunityJoinButton community={community} flex={1} />
<CommunityMenu community={community} aria-label="More" />
<CommunityMenu community={community} aria-label="More" onEditClick={onEditClick} />
</ButtonGroup>
<Box>
<Heading size="sm" mb="1">