add community browser

This commit is contained in:
hzrd149 2023-09-14 15:31:20 -05:00
parent adb7cb7655
commit 602131819c
15 changed files with 398 additions and 4 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add community browse view

View File

@ -56,6 +56,9 @@ import BadgesBrowseView from "./views/badges/browse";
import BadgeDetailsView from "./views/badges/badge-details";
import UserArticlesTab from "./views/user/articles";
import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
import CommunitiesHomeView from "./views/communities";
import CommunityFindByNameView from "./views/community/find-by-name";
import CommunityView from "./views/community/index";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -178,6 +181,17 @@ const router = createHashRouter([
{ path: ":addr", element: <ListDetailsView /> },
],
},
{
path: "communities",
element: <CommunitiesHomeView />,
},
{
path: "c/:community",
children: [
{ path: "", element: <CommunityFindByNameView /> },
{ path: ":pubkey", element: <CommunityView /> },
],
},
{
path: "goals",
children: [

View File

@ -421,3 +421,9 @@ export const PerformanceIcon = createIcon({
d: "M20 13C20 15.2091 19.1046 17.2091 17.6569 18.6569L19.0711 20.0711C20.8807 18.2614 22 15.7614 22 13 22 7.47715 17.5228 3 12 3 6.47715 3 2 7.47715 2 13 2 15.7614 3.11929 18.2614 4.92893 20.0711L6.34315 18.6569C4.89543 17.2091 4 15.2091 4 13 4 8.58172 7.58172 5 12 5 16.4183 5 20 8.58172 20 13ZM15.293 8.29297 10.793 12.793 12.2072 14.2072 16.7072 9.70718 15.293 8.29297Z",
defaultProps,
});
export const CommunityIcon = createIcon({
displayName: "CommunityIcon",
d: "M9.55 11.5C8.30736 11.5 7.3 10.4926 7.3 9.25C7.3 8.00736 8.30736 7 9.55 7C10.7926 7 11.8 8.00736 11.8 9.25C11.8 10.4926 10.7926 11.5 9.55 11.5ZM10 19.748V16.4C10 15.9116 10.1442 15.4627 10.4041 15.0624C10.1087 15.0213 9.80681 15 9.5 15C7.93201 15 6.49369 15.5552 5.37091 16.4797C6.44909 18.0721 8.08593 19.2553 10 19.748ZM4.45286 14.66C5.86432 13.6168 7.61013 13 9.5 13C10.5435 13 11.5431 13.188 12.4667 13.5321C13.3447 13.1888 14.3924 13 15.5 13C17.1597 13 18.6849 13.4239 19.706 14.1563C19.8976 13.4703 20 12.7471 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 12.9325 4.15956 13.8278 4.45286 14.66ZM18.8794 16.0859C18.4862 15.5526 17.1708 15 15.5 15C13.4939 15 12 15.7967 12 16.4V20C14.9255 20 17.4843 18.4296 18.8794 16.0859ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM15.5 12.5C14.3954 12.5 13.5 11.6046 13.5 10.5C13.5 9.39543 14.3954 8.5 15.5 8.5C16.6046 8.5 17.5 9.39543 17.5 10.5C17.5 11.6046 16.6046 12.5 15.5 12.5Z",
defaultProps,
});

View File

@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import {
BadgeIcon,
ChatIcon,
CommunityIcon,
EmojiIcon,
FeedIcon,
GoalIcon,
@ -50,6 +51,9 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean
<Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />} justifyContent="flex-start">
Lists
</Button>
<Button onClick={() => navigate("/communities")} leftIcon={<CommunityIcon />} justifyContent="flex-start">
Communities
</Button>
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
Goals
</Button>

View File

@ -47,7 +47,8 @@ export function safeDecode(str: string) {
} catch (e) {}
}
export function getPubkey(result: nip19.DecodeResult) {
export function getPubkey(result?: nip19.DecodeResult) {
if (!result) return;
switch (result.type) {
case "naddr":
case "nprofile":

View File

@ -0,0 +1,34 @@
import { NostrEvent, isDTag, isPTag } from "../../types/nostr-event";
export const COMMUNITY_DEFINITION_KIND = 34550;
export const COMMUNITY_APPROVAL_KIND = 4550;
export function getCommunityName(community: NostrEvent) {
const name = community.tags.find(isDTag)?.[1];
if (!name) throw new Error("Missing name");
return name;
}
export function getCommunityMods(community: NostrEvent) {
const mods = community.tags.filter((t) => isPTag(t) && t[1] && t[3] === "moderator").map((t) => t[1]) as string[];
return mods;
}
export function getCOmmunityRelays(community: NostrEvent) {
return community.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]) as string[];
}
export function getCommunityImage(community: NostrEvent) {
return community.tags.find((t) => t[0] === "image")?.[1];
}
export function getCommunityDescription(community: NostrEvent) {
return community.tags.find((t) => t[0] === "description")?.[1];
}
export function validateCommunity(community: NostrEvent) {
try {
getCommunityName(community);
return true;
} catch (e) {
return false;
}
}

View File

@ -0,0 +1,71 @@
import { memo, useRef } from "react";
import { Link as RouterLink } from "react-router-dom";
import {
Avatar,
Box,
Card,
CardProps,
Center,
Flex,
Heading,
Image,
LinkBox,
LinkOverlay,
Text,
} from "@chakra-ui/react";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getEventUID } from "../../../helpers/nostr/events";
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";
import CommunityDescription from "./community-description";
import { nip19 } from "nostr-tools";
import CommunityModList from "./community-mod-list";
function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & { community: NostrEvent }) {
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(community));
const image = getCommunityImage(community);
return (
<Card as={LinkBox} ref={ref} variant="outline" gap="2" overflow="hidden" {...props}>
{image ? (
<Box
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
aspectRatio={4 / 1}
/>
) : (
<Center aspectRatio={4 / 1} fontWeight="bold" fontSize="2xl">
{getCommunityName(community)}
</Center>
)}
<Flex direction="column" flex={1} px="2" pb="2">
<Flex wrap="wrap" gap="2" alignItems="center">
<Heading size="lg">
<LinkOverlay
as={RouterLink}
to={`/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`}
>
{getCommunityName(community)}
</LinkOverlay>
</Heading>
<Text>Created by:</Text>
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
</Flex>
<CommunityDescription community={community} maxLength={128} flex={1} />
<Flex gap="2">
<CommunityModList community={community} ml="auto" size="xs" />
</Flex>
</Flex>
</Card>
);
}
export default memo(CommunityCard);

View File

@ -0,0 +1,25 @@
import { Box, BoxProps } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityDescription } from "../../../helpers/nostr/communities";
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../../helpers/embeds";
import { renderGenericUrl } from "../../../components/embed-types";
export default function CommunityDescription({
community,
maxLength,
...props
}: Omit<BoxProps, "children"> & { community: NostrEvent; maxLength?: number }) {
const description = getCommunityDescription(community);
let content: EmbedableContent = description ? [description] : [];
content = embedUrls(content, [renderGenericUrl]);
if (maxLength !== undefined) {
content = truncateEmbedableContent(content, maxLength);
}
return (
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
);
}

View File

@ -0,0 +1,20 @@
import { AvatarGroup, AvatarGroupProps } from "@chakra-ui/react";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityMods } from "../../../helpers/nostr/communities";
export default function CommunityModList({
community,
...props
}: Omit<AvatarGroupProps, "children"> & { community: NostrEvent }) {
const mods = getCommunityMods(community);
return (
<AvatarGroup {...props}>
{mods.map((pubkey) => (
<UserAvatarLink pubkey={pubkey} />
))}
</AvatarGroup>
);
}

View File

@ -0,0 +1,66 @@
import { useCallback, useMemo } from "react";
import { Flex, SimpleGrid } from "@chakra-ui/react";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
import CommunityCard from "./components/community-card";
import { getEventUID } from "../../helpers/nostr/events";
import VerticalPageLayout from "../../components/vertical-page-layout";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider";
import { COMMUNITY_DEFINITION_KIND, validateCommunity } from "../../helpers/nostr/communities";
import { NostrEvent } from "../../types/nostr-event";
import { NostrQuery } from "../../types/nostr-query";
function CommunitiesHomePage() {
const { filter, listId } = usePeopleListContext();
const { relays } = useRelaySelectionContext();
const eventFilter = useCallback((event: NostrEvent) => {
return validateCommunity(event);
}, []);
const query = useMemo(() => {
const base: NostrQuery = { kinds: [COMMUNITY_DEFINITION_KIND] };
if (filter?.authors) {
base.authors = filter.authors;
base["#p"] = filter.authors;
}
return base;
}, [filter]);
const timeline = useTimelineLoader(`${listId}-browse-communities`, relays, query, { enabled: !!filter, eventFilter });
const communities = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<PeopleListSelection />
<RelaySelectionButton />
</Flex>
<SimpleGrid columns={[1, 1, 1, 2]} spacing="2">
{communities.map((event) => (
<CommunityCard key={getEventUID(event)} community={event} />
))}
</SimpleGrid>
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}
export default function CommunitiesHomeView() {
return (
<PeopleListProvider initList="global">
<RelaySelectionProvider>
<CommunitiesHomePage />
</RelaySelectionProvider>
</PeopleListProvider>
);
}

View File

@ -0,0 +1,74 @@
import { Avatar, Box, Flex, Heading, Text } from "@chakra-ui/react";
import {
COMMUNITY_APPROVAL_KIND,
getCOmmunityRelays,
getCommunityImage,
getCommunityMods,
getCommunityName,
} from "../../helpers/nostr/communities";
import { NostrEvent } from "../../types/nostr-event";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link";
import CommunityDescription from "../communities/components/community-description";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { getEventCoordinate, getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { unique } from "../../helpers/array";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import Note from "../../components/note";
export default function CommunityHomePage({ community }: { community: NostrEvent }) {
const mods = getCommunityMods(community);
const image = getCommunityImage(community);
const readRelays = useReadRelayUrls(getCOmmunityRelays(community));
const timeline = useTimelineLoader(`${getEventUID(community)}-appoved-posts`, readRelays, {
authors: unique([community.pubkey, ...mods]),
kinds: [COMMUNITY_APPROVAL_KIND],
"#a": [getEventCoordinate(community)],
});
const approvals = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<VerticalPageLayout>
{image && (
<Box
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
aspectRatio={4 / 1}
/>
)}
<Flex wrap="wrap" gap="2" alignItems="center">
<Heading size="lg">{getCommunityName(community)}</Heading>
<Text>Created by:</Text>
<Flex gap="2">
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
</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>
<IntersectionObserverProvider callback={callback}>
{approvals.map((approval) => (
<Note key={getEventUID(approval)} event={approval} />
))}
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}

View File

@ -0,0 +1,44 @@
import { useCallback } from "react";
import { useParams } from "react-router-dom";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { COMMUNITY_DEFINITION_KIND, validateCommunity } from "../../helpers/nostr/communities";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import useSubject from "../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import VerticalPageLayout from "../../components/vertical-page-layout";
import CommunityCard from "../communities/components/community-card";
import { getEventUID } from "../../helpers/nostr/events";
import { Divider, Heading } from "@chakra-ui/react";
export default function CommunityFindByNameView() {
const { community } = useParams() as { community: string };
const readRelays = useReadRelayUrls();
const eventFilter = useCallback((event: NostrEvent) => {
return validateCommunity(event);
}, []);
const timeline = useTimelineLoader(
`${community}-find-communities`,
readRelays,
{ kinds: [COMMUNITY_DEFINITION_KIND], "#d": [community] },
{ enabled: !!community },
);
const communities = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Heading>Select Community:</Heading>
<Divider />
{communities.map((event) => (
<CommunityCard key={getEventUID(event)} community={event} />
))}
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,29 @@
import { useParams } from "react-router-dom";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { Spinner } from "@chakra-ui/react";
import CommunityHomePage from "./community-home";
import { getPubkey, isHexKey, safeDecode } from "../../helpers/nip19";
function useCommunityPointer() {
const { community, pubkey } = useParams();
const decoded = community ? safeDecode(community) : undefined;
if (decoded) {
if (decoded.type === "naddr" && decoded.data.kind === COMMUNITY_DEFINITION_KIND) return decoded.data;
} else if (community && pubkey) {
const hexPubkey = isHexKey(pubkey) ? pubkey : getPubkey(safeDecode(pubkey));
if (!hexPubkey) return;
return { kind: COMMUNITY_DEFINITION_KIND, pubkey: hexPubkey, identifier: community };
}
}
export default function CommunityView() {
const pointer = useCommunityPointer();
const community = useReplaceableEvent(pointer);
if (!community) return <Spinner />;
return <CommunityHomePage community={community} />;
}

View File

@ -1,4 +1,6 @@
import { useCallback } from "react";
import { Flex, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react";
import dayjs from "dayjs";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
@ -10,10 +12,7 @@ import useSubject from "../../hooks/use-subject";
import GoalCard from "./components/goal-card";
import { getEventUID } from "../../helpers/nostr/events";
import { GOAL_KIND, getGoalClosedDate } from "../../helpers/nostr/goal";
import { SwipeState } from "yet-another-react-lightbox";
import { useCallback } from "react";
import { NostrEvent } from "../../types/nostr-event";
import dayjs from "dayjs";
import VerticalPageLayout from "../../components/vertical-page-layout";
function GoalsBrowsePage() {

View File

@ -5,6 +5,7 @@ import { STREAM_KIND } from "../../helpers/nostr/stream";
import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists";
import { ErrorBoundary } from "../../components/error-boundary";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
function NostrLinkPage() {
const { link } = useParams() as { link?: string };
@ -32,6 +33,7 @@ function NostrLinkPage() {
if (decoded.data.kind === EMOJI_PACK_KIND) return <Navigate to={`/emojis/${cleanLink}`} replace />;
if (decoded.data.kind === NOTE_LIST_KIND) return <Navigate to={`/lists/${cleanLink}`} replace />;
if (decoded.data.kind === PEOPLE_LIST_KIND) return <Navigate to={`/lists/${cleanLink}`} replace />;
if (decoded.data.kind === COMMUNITY_DEFINITION_KIND) return <Navigate to={`/c/${cleanLink}`} />;
}
return (