Add simple community views

This commit is contained in:
hzrd149 2023-09-25 21:42:09 -05:00
parent a62297c287
commit 0f876421ab
10 changed files with 183 additions and 66 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple community views

View File

@ -13,6 +13,7 @@ export default function EmbeddedCommunity({
...props ...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) { }: Omit<CardProps, "children"> & { community: NostrEvent }) {
const image = getCommunityImage(community); const image = getCommunityImage(community);
const name = getCommunityName(community);
return ( return (
<Card as={LinkBox} variant="outline" gap="2" overflow="hidden" {...props}> <Card as={LinkBox} variant="outline" gap="2" overflow="hidden" {...props}>
@ -20,23 +21,20 @@ export default function EmbeddedCommunity({
<Box <Box
backgroundImage={getCommunityImage(community)} backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat" backgroundRepeat="no-repeat"
backgroundSize="cover" backgroundSize="contain"
backgroundPosition="center" backgroundPosition="center"
aspectRatio={3 / 1} aspectRatio={3 / 1}
/> />
) : ( ) : (
<Center aspectRatio={4 / 1} fontWeight="bold" fontSize="2xl"> <Center aspectRatio={4 / 1} fontWeight="bold" fontSize="2xl">
{getCommunityName(community)} {name}
</Center> </Center>
)} )}
<Flex direction="column" flex={1} px="2" pb="2"> <Flex direction="column" flex={1} px="2" pb="2">
<Flex wrap="wrap" gap="2" alignItems="center"> <Flex wrap="wrap" gap="2" alignItems="center">
<Heading size="lg"> <Heading size="lg">
<LinkOverlay <LinkOverlay as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
as={RouterLink} {name}
to={`/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`}
>
{getCommunityName(community)}
</LinkOverlay> </LinkOverlay>
</Heading> </Heading>
<Text>Created by:</Text> <Text>Created by:</Text>

View File

@ -101,6 +101,14 @@ export default function NavItems() {
> >
Streams Streams
</Button> </Button>
<Button
onClick={() => navigate("/communities")}
leftIcon={<CommunityIcon />}
colorScheme={active === "communities" ? "brand" : undefined}
{...buttonProps}
>
Communities
</Button>
<Button <Button
onClick={() => navigate("/lists")} onClick={() => navigate("/lists")}
leftIcon={<ListIcon />} leftIcon={<ListIcon />}
@ -109,14 +117,6 @@ export default function NavItems() {
> >
Lists Lists
</Button> </Button>
{/* <Button
onClick={() => navigate("/communities")}
leftIcon={<CommunityIcon />}
colorScheme={active === "communities" ? "brand" : undefined}
{...buttonProps}
>
Communities
</Button> */}
<Button <Button
onClick={() => navigate("/goals")} onClick={() => navigate("/goals")}
leftIcon={<GoalIcon />} leftIcon={<GoalIcon />}

View File

@ -10,11 +10,13 @@ import {
Flex, Flex,
IconButton, IconButton,
Link, Link,
Text,
useBreakpointValue, useBreakpointValue,
useDisclosure, useDisclosure,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent, isATag } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link"; import { UserAvatarLink } from "../user-avatar-link";
import { Link as RouterLink } from "react-router-dom";
import { NoteMenu } from "./note-menu"; import { NoteMenu } from "./note-menu";
import { EventRelays } from "./note-relays"; import { EventRelays } from "./note-relays";
@ -36,10 +38,12 @@ import BookmarkButton from "./components/bookmark-button";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import NoteReactions from "./components/note-reactions"; import NoteReactions from "./components/note-reactions";
import ReplyForm from "../../views/note/components/reply-form"; import ReplyForm from "../../views/note/components/reply-form";
import { getReferences } from "../../helpers/nostr/events"; import { getEventCoordinate, getReferences, parseCoordinate } from "../../helpers/nostr/events";
import Timestamp from "../timestamp"; import Timestamp from "../timestamp";
import OpenInDrawerButton from "../open-in-drawer-button"; import OpenInDrawerButton from "../open-in-drawer-button";
import { getSharableEventAddress } from "../../helpers/nip19"; import { getSharableEventAddress } from "../../helpers/nip19";
import { COMMUNITY_DEFINITION_KIND, getCommunityName } from "../../helpers/nostr/communities";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
export type NoteProps = Omit<CardProps, "children"> & { export type NoteProps = Omit<CardProps, "children"> & {
event: NostrEvent; event: NostrEvent;
@ -67,6 +71,11 @@ export const Note = React.memo(
// find mostr external link // find mostr external link
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1]; const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
const communityPointer = useMemo(() => {
const tag = event.tags.find((t) => isATag(t) && t[1].startsWith(COMMUNITY_DEFINITION_KIND + ":"));
return tag?.[1] ? parseCoordinate(tag[1], true) : undefined;
}, [event]);
const community = useReplaceableEvent(communityPointer);
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false }); const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
@ -81,7 +90,7 @@ export const Note = React.memo(
data-event-id={event.id} data-event-id={event.id}
{...props} {...props}
> >
<CardHeader padding="2"> <CardHeader p="2">
<Flex flex="1" gap="2" alignItems="center"> <Flex flex="1" gap="2" alignItems="center">
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} /> <UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" /> <UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
@ -95,6 +104,15 @@ export const Note = React.memo(
<Timestamp timestamp={event.created_at} /> <Timestamp timestamp={event.created_at} />
</NoteLink> </NoteLink>
</Flex> </Flex>
{community && (
<Text fontStyle="italic">
Posted in{" "}
<Link as={RouterLink} to={`/c/${getCommunityName(community)}/${community.pubkey}`} color="blue.500">
{getCommunityName(community)}
</Link>{" "}
community
</Text>
)}
</CardHeader> </CardHeader>
<CardBody p="0"> <CardBody p="0">
<NoteContentWithWarning event={event} /> <NoteContentWithWarning event={event} />

View File

@ -0,0 +1,30 @@
import { Select, SelectProps } from "@chakra-ui/react";
import useSubscribedCommunitiesList from "../../hooks/use-subscribed-communities-list";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { getCommunityName } from "../../helpers/nostr/communities";
import { AddressPointer } from "nostr-tools/lib/nip19";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { getEventCoordinate } from "../../helpers/nostr/events";
import { forwardRef } from "react";
function CommunityOption({ pointer }: { pointer: AddressPointer }) {
const community = useReplaceableEvent(pointer);
if (!community) return;
return <option value={getEventCoordinate(community)}>{getCommunityName(community)}</option>;
}
const CommunitySelect = forwardRef<HTMLSelectElement, Omit<SelectProps, "children">>(({ ...props }, ref) => {
const account = useCurrentAccount();
const { pointers } = useSubscribedCommunitiesList(account?.pubkey);
return (
<Select placeholder="Select community" {...props} ref={ref}>
{pointers.map((pointer) => (
<CommunityOption key={pointer.identifier + pointer.pubkey} pointer={pointer} />
))}
</Select>
);
});
export default CommunitySelect;

View File

@ -15,6 +15,8 @@ import {
ModalProps, ModalProps,
VisuallyHiddenInput, VisuallyHiddenInput,
IconButton, IconButton,
FormLabel,
FormControl,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -32,6 +34,7 @@ import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea, { RefType } from "../magic-textarea"; import MagicTextArea, { RefType } from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider"; import { useContextEmojis } from "../../providers/emoji-provider";
import { nostrBuildUploadImage } from "../../helpers/nostr-build"; import { nostrBuildUploadImage } from "../../helpers/nostr-build";
import CommunitySelect from "./community-select";
export default function PostModal({ export default function PostModal({
isOpen, isOpen,
@ -50,6 +53,7 @@ export default function PostModal({
content: initContent, content: initContent,
nsfw: false, nsfw: false,
nsfwReason: "", nsfwReason: "",
community: "",
}, },
}); });
watch("content"); watch("content");
@ -82,7 +86,7 @@ export default function PostModal({
); );
const getDraft = useCallback(() => { const getDraft = useCallback(() => {
const { content, nsfw, nsfwReason } = getValues(); const { content, nsfw, nsfwReason, community } = getValues();
let updatedDraft = finalizeNote({ let updatedDraft = finalizeNote({
content: content, content: content,
@ -94,6 +98,9 @@ export default function PostModal({
if (nsfw) { if (nsfw) {
updatedDraft.tags.push(nsfwReason ? ["content-warning", nsfwReason] : ["content-warning"]); updatedDraft.tags.push(nsfwReason ? ["content-warning", nsfwReason] : ["content-warning"]);
} }
if (community) {
updatedDraft.tags.push(["a", community]);
}
const contentMentions = getContentMentions(updatedDraft.content); const contentMentions = getContentMentions(updatedDraft.content);
updatedDraft = createEmojiTags(updatedDraft, emojis); updatedDraft = createEmojiTags(updatedDraft, emojis);
@ -190,6 +197,10 @@ export default function PostModal({
</Flex> </Flex>
{moreOptions.isOpen && ( {moreOptions.isOpen && (
<> <>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect w="sm" {...register("community")} />
</FormControl>
<Flex gap="2" direction="column"> <Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch> <Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" maxW="50%" />} {getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" maxW="50%" />}

View File

@ -1,3 +1,4 @@
import { validateEvent } from "nostr-tools";
import { NostrEvent, isDTag, isPTag } from "../../types/nostr-event"; import { NostrEvent, isDTag, isPTag } from "../../types/nostr-event";
export const SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER = "communities"; export const SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER = "communities";
@ -25,6 +26,16 @@ export function getCommunityDescription(community: NostrEvent) {
return community.tags.find((t) => t[0] === "description")?.[1]; return community.tags.find((t) => t[0] === "description")?.[1];
} }
export function getApprovedEmbeddedNote(approval: NostrEvent) {
if (!approval.content) return null;
try {
const json = JSON.parse(approval.content);
validateEvent(json);
return (json as NostrEvent) ?? null;
} catch (e) {}
return null;
}
export function validateCommunity(community: NostrEvent) { export function validateCommunity(community: NostrEvent) {
try { try {
getCommunityName(community); getCommunityName(community);

View File

@ -153,11 +153,20 @@ export function getEventCoordinate(event: NostrEvent) {
const d = event.tags.find((t) => t[0] === "d")?.[1]; const d = event.tags.find((t) => t[0] === "d")?.[1];
return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`; return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`;
} }
export function pointerToATag(pointer: AddressPointer): ATag {
const relay = pointer.relays?.[0];
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
return relay ? ["a", coordinate, relay] : ["a", coordinate];
}
export type CustomEventPointer = Omit<AddressPointer, "identifier"> & { export type CustomEventPointer = Omit<AddressPointer, "identifier"> & {
identifier?: string; identifier?: string;
}; };
export function parseCoordinate(a: string): CustomEventPointer | null {
export function parseCoordinate(a: string): CustomEventPointer | null;
export function parseCoordinate(a: string, requireD: false): CustomEventPointer | null;
export function parseCoordinate(a: string, requireD: true): AddressPointer;
export function parseCoordinate(a: string, requireD = false): CustomEventPointer | null {
const parts = a.split(":") as (string | undefined)[]; const parts = a.split(":") as (string | undefined)[];
const kind = parts[0] && parseInt(parts[0]); const kind = parts[0] && parseInt(parts[0]);
const pubkey = parts[1]; const pubkey = parts[1];
@ -165,6 +174,7 @@ export function parseCoordinate(a: string): CustomEventPointer | null {
if (!kind) return null; if (!kind) return null;
if (!pubkey) return null; if (!pubkey) return null;
if (requireD && !d) return null;
return { return {
kind, kind,

View File

@ -17,6 +17,7 @@ function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(community)); useRegisterIntersectionEntity(ref, getEventUID(community));
const name = getCommunityName(community);
const image = getCommunityImage(community); const image = getCommunityImage(community);
return ( return (
@ -25,23 +26,20 @@ function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & {
<Box <Box
backgroundImage={getCommunityImage(community)} backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat" backgroundRepeat="no-repeat"
backgroundSize="cover" backgroundSize="contain"
backgroundPosition="center" backgroundPosition="center"
aspectRatio={3 / 1} aspectRatio={3 / 1}
/> />
) : ( ) : (
<Center aspectRatio={3 / 1} fontWeight="bold" fontSize="2xl"> <Center aspectRatio={3 / 1} fontWeight="bold" fontSize="2xl">
{getCommunityName(community)} {name}
</Center> </Center>
)} )}
<Flex direction="column" flex={1} px="2" pb="2"> <Flex direction="column" flex={1} px="2" pb="2">
<Flex wrap="wrap" gap="2" alignItems="center"> <Flex wrap="wrap" gap="2" alignItems="center">
<Heading size="lg"> <Heading size="lg">
<LinkOverlay <LinkOverlay as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
as={RouterLink} {name}
to={`/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`}
>
{getCommunityName(community)}
</LinkOverlay> </LinkOverlay>
</Heading> </Heading>
<Text>Created by:</Text> <Text>Created by:</Text>

View File

@ -1,13 +1,15 @@
import { Avatar, Box, Flex, Heading, Text } from "@chakra-ui/react"; import { useRef } from "react";
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
import { import {
COMMUNITY_APPROVAL_KIND, COMMUNITY_APPROVAL_KIND,
getCOmmunityRelays, getApprovedEmbeddedNote,
getCOmmunityRelays as getCommunityRelays,
getCommunityImage, getCommunityImage,
getCommunityMods, getCommunityMods,
getCommunityName, getCommunityName,
} from "../../helpers/nostr/communities"; } from "../../helpers/nostr/communities";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent, isETag } from "../../types/nostr-event";
import VerticalPageLayout from "../../components/vertical-page-layout"; import VerticalPageLayout from "../../components/vertical-page-layout";
import { UserAvatarLink } from "../../components/user-avatar-link"; import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
@ -18,16 +20,41 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { unique } from "../../helpers/array"; import { unique } from "../../helpers/array";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer"; import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import Note from "../../components/note";
import CommunityJoinButton from "../communities/components/community-subscribe-button"; import CommunityJoinButton from "../communities/components/community-subscribe-button";
import useSingleEvent from "../../hooks/use-single-event";
import { EmbedEvent } from "../../components/embed-event";
import { AdditionalRelayProvider, useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { RelayIconStack } from "../../components/relay-icon-stack";
function ApprovedEvent({ approval }: { approval: NostrEvent }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(approval));
const additionalRelays = useAdditionalRelayContext();
const embeddedEvent = getApprovedEmbeddedNote(approval);
const eventTag = approval.tags.find(isETag);
const loadEvent = useSingleEvent(
eventTag?.[1],
eventTag?.[2] ? [eventTag[2], ...additionalRelays] : additionalRelays,
);
const event = loadEvent || embeddedEvent;
if (!event) return;
return (
<Box ref={ref}>
<EmbedEvent event={event} />
</Box>
);
}
export default function CommunityHomePage({ community }: { community: NostrEvent }) { export default function CommunityHomePage({ community }: { community: NostrEvent }) {
const mods = getCommunityMods(community); const mods = getCommunityMods(community);
const image = getCommunityImage(community); const image = getCommunityImage(community);
const readRelays = useReadRelayUrls(getCOmmunityRelays(community)); const communityRelays = getCommunityRelays(community);
const timeline = useTimelineLoader(`${getEventUID(community)}-appoved-posts`, readRelays, { const readRelays = useReadRelayUrls(communityRelays);
const timeline = useTimelineLoader(`${getEventUID(community)}-approved-posts`, readRelays, {
authors: unique([community.pubkey, ...mods]), authors: unique([community.pubkey, ...mods]),
kinds: [COMMUNITY_APPROVAL_KIND], kinds: [COMMUNITY_APPROVAL_KIND],
"#a": [getEventCoordinate(community)], "#a": [getEventCoordinate(community)],
@ -37,40 +64,49 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
const callback = useTimelineCurserIntersectionCallback(timeline); const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<VerticalPageLayout> <AdditionalRelayProvider relays={communityRelays}>
{image && ( <VerticalPageLayout pt={image && "0"}>
<Box {image && (
backgroundImage={getCommunityImage(community)} <Box
backgroundRepeat="no-repeat" backgroundImage={getCommunityImage(community)}
backgroundSize="cover" backgroundRepeat="no-repeat"
backgroundPosition="center" backgroundSize="contain"
aspectRatio={4 / 1} backgroundPosition="center"
/> aspectRatio={4 / 1}
)} backgroundColor="rgba(0,0,0,0.2)"
<Flex wrap="wrap" gap="2" alignItems="center"> />
<Heading size="lg">{getCommunityName(community)}</Heading> )}
<Text>Created by:</Text> <Flex wrap="wrap" gap="2" alignItems="center">
<Flex gap="2"> <Heading size="lg">{getCommunityName(community)}</Heading>
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} /> <Text>Created by:</Text>
</Flex>
<CommunityJoinButton community={community} ml="auto" />
</Flex>
<CommunityDescription community={community} />
<Flex wrap="wrap" gap="2">
<Text>Moderators:</Text>
{mods.map((pubkey) => (
<Flex gap="2"> <Flex gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" /> <UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
<UserLink pubkey={pubkey} />
</Flex> </Flex>
))} <CommunityJoinButton community={community} ml="auto" />
</Flex> </Flex>
<CommunityDescription community={community} />
<Flex wrap="wrap" gap="2">
<Text>Moderators:</Text>
{mods.map((pubkey) => (
<Flex gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
</Flex>
))}
</Flex>
{communityRelays.length > 0 && (
<Flex wrap="wrap" gap="2">
<Text>Relays:</Text>
<RelayIconStack relays={communityRelays} />
</Flex>
)}
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
{approvals.map((approval) => ( {approvals.map((approval) => (
<Note key={getEventUID(approval)} event={approval} /> <ApprovedEvent key={getEventUID(approval)} approval={approval} />
))} ))}
</IntersectionObserverProvider> </IntersectionObserverProvider>
</VerticalPageLayout> </VerticalPageLayout>
</AdditionalRelayProvider>
); );
} }