add vote buttons to communities

This commit is contained in:
hzrd149 2023-10-18 15:19:38 -05:00
parent 701a309a62
commit 5d66750768
22 changed files with 635 additions and 259 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add vote buttons to community view

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Improve community view on mobile

View File

@ -58,10 +58,11 @@ import BadgesBrowseView from "./views/badges/browse";
import BadgeDetailsView from "./views/badges/badge-details";
import CommunitiesHomeView from "./views/communities";
import CommunitiesExploreView from "./views/communities/explore";
import CommunityFindByNameView from "./views/community/find-by-name";
import CommunityView from "./views/community/index";
import CommunityPendingView from "./views/community/views/pending";
import CommunityNewView from "./views/community/views/new";
import CommunityNewestView from "./views/community/views/newest";
import RelaysView from "./views/relays";
import RelayView from "./views/relays/relay";
@ -236,7 +237,10 @@ const router = createHashRouter([
},
{
path: "communities",
element: <CommunitiesHomeView />,
children: [
{ path: "", element: <CommunitiesHomeView /> },
{ path: "explore", element: <CommunitiesExploreView /> },
],
},
{
path: "c/:community",
@ -246,7 +250,7 @@ const router = createHashRouter([
path: ":pubkey",
element: <CommunityView />,
children: [
{ path: "", element: <CommunityNewView /> },
{ path: "", element: <CommunityNewestView /> },
{ path: "pending", element: <CommunityPendingView /> },
],
},

View File

@ -1,47 +1,44 @@
import { Link as RouterLink } from "react-router-dom";
import { Box, Card, CardProps, Center, Flex, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import { Card, CardFooter, CardHeader, CardProps, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";
import CommunityDescription from "../../../views/communities/components/community-description";
export default function EmbeddedCommunity({
community,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
const image = getCommunityImage(community);
const name = getCommunityName(community);
return (
<Card as={LinkBox} variant="outline" gap="2" overflow="hidden" {...props}>
{image ? (
<Box
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="contain"
backgroundPosition="center"
aspectRatio={3 / 1}
/>
) : (
<Center aspectRatio={4 / 1} fontWeight="bold" fontSize="2xl">
{name}
</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(name)}/${nip19.npubEncode(community.pubkey)}`}>
{name}
</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>
<Card
as={LinkBox}
variant="outline"
gap="2"
overflow="hidden"
borderRadius="xl"
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
textShadow="2px 2px var(--chakra-blur-sm) var(--chakra-colors-blackAlpha-800)"
{...props}
>
<CardHeader pb="0">
<Heading size="lg">
<LinkOverlay as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
{name}
</LinkOverlay>
</Heading>
</CardHeader>
<CardFooter display="flex" alignItems="center" gap="2" pt="0">
<UserAvatarLink pubkey={community.pubkey} size="sm" />
<Text>by</Text>
<UserLink pubkey={community.pubkey} />
</CardFooter>
</Card>
);
}

View File

@ -40,6 +40,7 @@ export default function NavItems() {
else if (location.pathname.startsWith("/relays")) active = "relays";
else if (location.pathname.startsWith("/lists")) active = "lists";
else if (location.pathname.startsWith("/communities")) active = "communities";
else if (location.pathname.startsWith("/c/")) active = "communities";
else if (location.pathname.startsWith("/goals")) active = "goals";
else if (location.pathname.startsWith("/badges")) active = "badges";
else if (location.pathname.startsWith("/emojis")) active = "emojis";

View File

@ -18,7 +18,7 @@ import { NostrEvent, isATag } from "../../types/nostr-event";
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 { UserLink } from "../user-link";
import { UserDnsIdentityIcon } from "../user-dns-identity-icon";

View File

@ -29,7 +29,7 @@ import NostrPublishAction from "../../classes/nostr-publish-action";
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
import { useMuteModalContext } from "../../providers/mute-modal-provider";
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
@ -101,4 +101,4 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
)}
</>
);
};
}

View File

@ -4,7 +4,7 @@ import { Kind, validateEvent } from "nostr-tools";
import { isETag, NostrEvent } from "../../../types/nostr-event";
import { Note } from "../../note";
import { NoteMenu } from "../../note/note-menu";
import NoteMenu from "../../note/note-menu";
import { UserAvatar } from "../../user-avatar";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import { UserLink } from "../../user-link";

View File

@ -25,6 +25,9 @@ export function getCommunityImage(community: NostrEvent) {
export function getCommunityDescription(community: NostrEvent) {
return community.tags.find((t) => t[0] === "description")?.[1];
}
export function getCommunityRules(community: NostrEvent) {
return community.tags.find((t) => t[0] === "rules")?.[1];
}
export function getApprovedEmbeddedNote(approval: NostrEvent) {
if (!approval.content) return null;

View File

@ -1,27 +1,39 @@
import stringify from "json-stringify-deterministic";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrRequestFilter } from "../types/nostr-query";
import NostrRequest from "../classes/nostr-request";
import relayPoolService from "./relay-pool";
// TODO: move this to settings
const COUNT_RELAY = "wss://relay.nostr.band";
const RATE_LIMIT = 10 / 1000;
class EventCountService {
subjects = new SuperMap<string, Subject<number>>(() => new Subject<number>());
constructor() {
window.setInterval(this.makeRequest.bind(this), RATE_LIMIT);
relayPoolService.addClaim(COUNT_RELAY, this);
}
stringifyFilter(filter: NostrRequestFilter) {
return stringify(filter);
}
private queue: NostrRequestFilter[] = [];
private queueKeys = new Set<string>();
requestCount(filter: NostrRequestFilter, alwaysRequest = false) {
const key = this.stringifyFilter(filter);
const sub = this.subjects.get(key);
if (sub.value === undefined || alwaysRequest) {
const request = new NostrRequest([COUNT_RELAY]);
request.onCount.subscribe((c) => sub.next(c.count));
request.start(filter, "COUNT");
if (!this.queueKeys.has(key)) {
this.queue.push(filter);
this.queueKeys.add(key);
}
}
return sub;
@ -32,6 +44,19 @@ class EventCountService {
const sub = this.subjects.get(key);
return sub;
}
makeRequest() {
const filter = this.queue.pop();
if (!filter) return;
const key = this.stringifyFilter(filter);
this.queueKeys.delete(key);
const sub = this.subjects.get(key);
const request = new NostrRequest([COUNT_RELAY]);
request.onCount.subscribe((c) => sub.next(c.count));
request.start(filter, "COUNT");
}
}
const eventCountService = new EventCountService();

View File

@ -1,55 +1,67 @@
import { memo, useRef } from "react";
import { nip19 } from "nostr-tools";
import { Kind, nip19 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import { Box, Card, CardProps, Center, Flex, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import {
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Heading,
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 CommunityModList from "./community-mod-list";
import useCountCommunityPosts from "../hooks/use-count-community-post";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
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 name = getCommunityName(community);
const image = getCommunityImage(community);
// NOTE: disabled because nostr.band has a rate limit
// const notesInLastMonth = useCountCommunityPosts(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={3 / 1}
/>
) : (
<Center aspectRatio={3 / 1} fontWeight="bold" fontSize="2xl">
{name}
</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(name)}/${nip19.npubEncode(community.pubkey)}`}>
{name}
</LinkOverlay>
</Heading>
<Text>Created by:</Text>
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
</Flex>
<Card
as={LinkBox}
ref={ref}
variant="outline"
gap="2"
overflow="hidden"
borderRadius="xl"
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
textShadow="2px 2px var(--chakra-blur-sm) var(--chakra-colors-blackAlpha-800)"
{...props}
>
<CardHeader pb="0">
<Heading size="lg">
<LinkOverlay as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
{name}
</LinkOverlay>
</Heading>
</CardHeader>
<CardBody>
<CommunityDescription community={community} maxLength={128} flex={1} />
<Flex gap="2">
<CommunityModList community={community} ml="auto" size="xs" />
</Flex>
</Flex>
</CardBody>
<CardFooter display="flex" alignItems="center" gap="2" pt="0">
<UserAvatarLink pubkey={community.pubkey} size="sm" />
<Text>by</Text>
<UserLink pubkey={community.pubkey} />
{/* {notesInLastMonth !== undefined && <Text ml="auto">{notesInLastMonth} Posts in the past month</Text>} */}
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,69 @@
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";
import { ErrorBoundary } from "../../components/error-boundary";
function CommunitiesExplorePage() {
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 spacing="2" columns={{ base: 1, lg: 2 }}>
{communities.map((event) => (
<ErrorBoundary>
<CommunityCard key={getEventUID(event)} community={event} />
</ErrorBoundary>
))}
</SimpleGrid>
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}
export default function ExploreCommunitiesView() {
return (
<PeopleListProvider initList="global">
<RelaySelectionProvider>
<CommunitiesExplorePage />
</RelaySelectionProvider>
</PeopleListProvider>
);
}

View File

@ -0,0 +1,13 @@
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { NostrEvent } from "../../../types/nostr-event";
import useEventCount from "../../../hooks/use-event-count";
import { getEventCoordinate } from "../../../helpers/nostr/events";
export default function useCountCommunityPosts(
community: NostrEvent,
since: number = dayjs().subtract(1, "month").unix(),
) {
return useEventCount({ "#a": [getEventCoordinate(community)], kinds: [Kind.Text], since });
}

View File

@ -1,69 +1,48 @@
import { useCallback, useMemo } from "react";
import { Flex, SimpleGrid } from "@chakra-ui/react";
import { Button, Center, Flex, Heading, Link, SimpleGrid, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
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";
import { ErrorBoundary } from "../../components/error-boundary";
import useSubscribedCommunitiesList from "../../hooks/use-subscribed-communities-list";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { Navigate } from "react-router-dom";
import { EmbedEventPointer } from "../../components/embed-event";
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);
const account = useCurrentAccount()!;
const { pointers: communities } = useSubscribedCommunitiesList(account.pubkey);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<PeopleListSelection />
<RelaySelectionButton />
</Flex>
<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((event) => (
{communities.map((pointer) => (
<ErrorBoundary>
<CommunityCard key={getEventUID(event)} community={event} />
<EmbedEventPointer pointer={{ type: "naddr", data: pointer }} />
</ErrorBoundary>
))}
</SimpleGrid>
</VerticalPageLayout>
</IntersectionObserverProvider>
) : (
<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>
);
}
export default function CommunitiesHomeView() {
return (
<PeopleListProvider initList="global">
<RelaySelectionProvider>
<CommunitiesHomePage />
</RelaySelectionProvider>
</PeopleListProvider>
);
const account = useCurrentAccount();
return account ? <CommunitiesHomePage /> : <Navigate to="/communities/explore" />;
}

View File

@ -21,46 +21,9 @@ import { RelayIconStack } from "../../components/relay-icon-stack";
import TrendUp01 from "../../components/icons/trend-up-01";
import Clock from "../../components/icons/clock";
import Hourglass03 from "../../components/icons/hourglass-03";
function CommunityDetails({ community }: { community: NostrEvent }) {
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
const description = getCommunityDescription(community);
return (
<Card p="4" w="xs" flexShrink={0}>
{description && (
<>
<Heading size="sm" mb="2">
Description:
</Heading>
<CommunityDescription community={community} maxLength={256} showExpand />
</>
)}
<Heading size="sm" mt="4" mb="2">
Moderators:
</Heading>
<Flex direction="column" gap="2">
{mods.map((pubkey) => (
<Flex gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
</Flex>
))}
</Flex>
{communityRelays.length > 0 && (
<>
<Heading size="sm" mt="4" mb="2">
Relays:
</Heading>
<Flex direction="column" gap="2">
<RelayIconStack relays={communityRelays} />
</Flex>
</>
)}
</Card>
);
}
import VerticalCommunityDetails from "./components/vertical-community-details";
import { useBreakpointValue } from "../../providers/breakpoint-provider";
import HorizontalCommunityDetails from "./components/horizonal-community-details";
function getCommunityPath(community: NostrEvent) {
return `/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`;
@ -70,6 +33,8 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
const image = getCommunityImage(community);
const location = useLocation();
const verticalLayout = useBreakpointValue({ base: true, xl: false });
const communityRelays = getCommunityRelays(community);
let active = "new";
@ -78,27 +43,31 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
return (
<AdditionalRelayProvider relays={communityRelays}>
<VerticalPageLayout pt={image && "0"}>
{image && (
<Box
backgroundImage={getCommunityImage(community)}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
aspectRatio={3 / 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 gap="2">
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
<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>
<CommunityJoinButton community={community} ml="auto" />
</Flex>
<Flex gap="4" alignItems="flex-start">
<Flex direction="column" gap="4" flex={1}>
{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 leftIcon={<TrendUp01 />} isDisabled>
Trending
@ -124,7 +93,7 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
<Outlet context={{ community }} />
</Flex>
<CommunityDetails community={community} />
{!verticalLayout && <VerticalCommunityDetails community={community} w="full" maxW="xs" flexShrink={0} />}
</Flex>
</VerticalPageLayout>
</AdditionalRelayProvider>

View File

@ -0,0 +1,41 @@
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { NostrEvent } from "../../../types/nostr-event";
import { CodeIcon, ExternalLinkIcon, RepostIcon } from "../../../components/icons";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { getSharableEventAddress } from "../../../helpers/nip19";
export default function CommunityMenu({
community,
...props
}: Omit<MenuIconButtonProps, "children"> & { community: NostrEvent }) {
const debugModal = useDisclosure();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const address = getSharableEventAddress(community);
return (
<>
<CustomMenuIconButton {...props}>
{address && (
<MenuItem onClick={() => window.open(buildAppSelectUrl(address), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
)}
<MenuItem onClick={() => copyToClipboard("nostr:" + address)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
<MenuItem onClick={debugModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</CustomMenuIconButton>
{debugModal.isOpen && (
<NoteDebugModal event={community} isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl" />
)}
</>
);
}

View File

@ -0,0 +1,97 @@
import {
Box,
Button,
ButtonGroup,
Card,
CardBody,
CardProps,
Flex,
Heading,
SimpleGrid,
Text,
useDisclosure,
} from "@chakra-ui/react";
import {
getCommunityDescription,
getCommunityMods,
getCommunityRelays,
getCommunityRules,
} from "../../../helpers/nostr/communities";
import CommunityDescription from "../../communities/components/community-description";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { RelayIconStack } from "../../../components/relay-icon-stack";
import { NostrEvent } from "../../../types/nostr-event";
import CommunityJoinButton from "../../communities/components/community-subscribe-button";
import CommunityMenu from "./community-menu";
export default function HorizontalCommunityDetails({
community,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
const description = getCommunityDescription(community);
const rules = getCommunityRules(community);
const more = useDisclosure();
return (
<Card {...props}>
<CardBody>
<ButtonGroup float="right">
<CommunityJoinButton community={community} />
<CommunityMenu community={community} aria-label="More" />
</ButtonGroup>
{description && (
<>
<Heading size="sm" mb="2">
Description
</Heading>
<CommunityDescription community={community} mb="2" />
</>
)}
{more.isOpen ? (
<SimpleGrid spacing="4" columns={2}>
<Box>
<Heading size="sm" mb="2">
Mods
</Heading>
<Flex direction="column" gap="2">
{mods.map((pubkey) => (
<Flex gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
</Flex>
))}
</Flex>
</Box>
{rules && (
<Box>
<Heading size="sm" mb="2">
Rules
</Heading>
<Text whiteSpace="pre-wrap">{rules}</Text>
</Box>
)}
{communityRelays.length > 0 && (
<Box>
<Heading size="sm" mt="4" mb="2">
Relays
</Heading>
<Flex direction="column" gap="2">
<RelayIconStack relays={communityRelays} />
</Flex>
</Box>
)}
</SimpleGrid>
) : (
<Button variant="link" onClick={more.onOpen} w="full">
Show more
</Button>
)}
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,70 @@
import { Box, ButtonGroup, Card, CardProps, Flex, Heading, Text } from "@chakra-ui/react";
import {
getCommunityDescription,
getCommunityMods,
getCommunityRelays,
getCommunityRules,
} from "../../../helpers/nostr/communities";
import CommunityDescription from "../../communities/components/community-description";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { RelayIconStack } from "../../../components/relay-icon-stack";
import { NostrEvent } from "../../../types/nostr-event";
import CommunityJoinButton from "../../communities/components/community-subscribe-button";
import CommunityMenu from "./community-menu";
export default function VerticalCommunityDetails({
community,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
const description = getCommunityDescription(community);
const rules = getCommunityRules(community);
return (
<Card p="4" {...props}>
{description && (
<>
<Heading size="sm" mb="2">
About
</Heading>
<CommunityDescription community={community} maxLength={256} showExpand />
</>
)}
<ButtonGroup w="full" my="2">
<CommunityJoinButton community={community} flex={1} />
<CommunityMenu community={community} aria-label="More" />
</ButtonGroup>
<Heading size="sm" mt="4" mb="2">
Mods
</Heading>
<Flex direction="column" gap="2">
{mods.map((pubkey) => (
<Flex gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
</Flex>
))}
</Flex>
{rules && (
<>
<Heading size="sm" mt="4" mb="2">
Rules
</Heading>
<Text whiteSpace="pre-wrap">{rules}</Text>
</>
)}
{communityRelays.length > 0 && (
<>
<Heading size="sm" mt="4" mb="2">
Relays
</Heading>
<Flex direction="column" gap="2">
<RelayIconStack relays={communityRelays} />
</Flex>
</>
)}
</Card>
);
}

View File

@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useParams } from "react-router-dom";
import { Navigate, useParams } from "react-router-dom";
import { Heading, SimpleGrid } from "@chakra-ui/react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { COMMUNITY_DEFINITION_KIND, validateCommunity } from "../../helpers/nostr/communities";
@ -11,11 +12,17 @@ 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, SimpleGrid } from "@chakra-ui/react";
import { safeDecode } from "../../helpers/nip19";
export default function CommunityFindByNameView() {
const { community } = useParams() as { community: string };
// if community name is a naddr, redirect
const decoded = safeDecode(community);
if (decoded?.type === "naddr" && decoded.data.kind === COMMUNITY_DEFINITION_KIND) {
return <Navigate to={`/c/${decoded.data.identifier}/${decoded.data.pubkey}`} replace />;
}
const readRelays = useReadRelayUrls();
const eventFilter = useCallback((event: NostrEvent) => {
return validateCommunity(event);
@ -33,8 +40,7 @@ export default function CommunityFindByNameView() {
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Heading>Select Community:</Heading>
<Divider />
<Heading>Select Community</Heading>
<SimpleGrid spacing="2" columns={{ base: 1, lg: 2 }}>
{communities.map((event) => (
<CommunityCard key={getEventUID(event)} community={event} />

View File

@ -1,69 +0,0 @@
import { useRef } from "react";
import { Box } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { unique } from "../../../helpers/array";
import {
COMMUNITY_APPROVAL_KIND,
getApprovedEmbeddedNote,
getCommunityMods,
getCommunityRelays,
} from "../../../helpers/nostr/communities";
import { getEventCoordinate, getEventUID } from "../../../helpers/nostr/events";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import useSubject from "../../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import { NostrEvent, isETag } from "../../../types/nostr-event";
import { EmbedEvent } from "../../../components/embed-event";
import useSingleEvent from "../../../hooks/use-single-event";
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status";
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 CommunityNewView() {
const { community } = useOutletContext() as { community: NostrEvent };
const mods = getCommunityMods(community);
const readRelays = useReadRelayUrls(getCommunityRelays(community));
const timeline = useTimelineLoader(`${getEventUID(community)}-approved-posts`, readRelays, {
authors: unique([community.pubkey, ...mods]),
kinds: [COMMUNITY_APPROVAL_KIND],
"#a": [getEventCoordinate(community)],
});
const approvals = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<>
<IntersectionObserverProvider callback={callback}>
{approvals.map((approval) => (
<ApprovedEvent key={getEventUID(approval)} approval={approval} />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} />
</>
);
}

View File

@ -0,0 +1,149 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Box, Card, Flex, IconButton, Text, useToast } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { unique } from "../../../helpers/array";
import {
COMMUNITY_APPROVAL_KIND,
getApprovedEmbeddedNote,
getCommunityMods,
getCommunityRelays,
} from "../../../helpers/nostr/communities";
import { getEventCoordinate, getEventUID } from "../../../helpers/nostr/events";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import useSubject from "../../../hooks/use-subject";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import { NostrEvent, isETag } from "../../../types/nostr-event";
import { EmbedEvent } from "../../../components/embed-event";
import useSingleEvent from "../../../hooks/use-single-event";
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import TimelineActionAndStatus from "../../../components/timeline-page/timeline-action-and-status";
import { ChevronUpIcon } from "../../../components/icons";
import { ChevronDownIcon } from "@chakra-ui/icons";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useMeasure, useStartTyping } from "react-use";
import { draftEventReaction } from "../../../helpers/nostr/reactions";
import eventReactionsService from "../../../services/event-reactions";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
import { useSigningContext } from "../../../providers/signing-provider";
import { useCurrentAccount } from "../../../hooks/use-current-account";
function ApprovalVoteButtons({ event, community }: { event: NostrEvent; community: NostrEvent }) {
const account = useCurrentAccount();
const reactions = useEventReactions(event.id);
const toast = useToast();
const voteReactions = useMemo(() => {
return reactions?.filter((r) => r.content === "+" || r.content === "-") ?? [];
}, [reactions]);
const vote = useMemo(() => {
return voteReactions.reduce((t, r) => {
if (r.content === "+") return t + 1;
else if (r.content === "-") return t - 1;
return t;
}, 0);
}, [voteReactions]);
const myVote = reactions?.find((e) => e.pubkey === account?.pubkey);
const { requestSignature } = useSigningContext();
const [loading, setLoading] = useState(false);
const addVote = useCallback(
async (vote: string) => {
setLoading(true);
try {
const draft = draftEventReaction(event, vote);
const signed = await requestSignature(draft);
if (signed) {
const writeRelays = clientRelaysService.getWriteUrls();
const communityRelays = getCommunityRelays(community);
new NostrPublishAction("Reaction", unique([...writeRelays, ...communityRelays]), signed);
eventReactionsService.handleEvent(signed);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
},
[event, community, requestSignature],
);
return (
<Card direction="column" alignItems="center" borderRadius="lg">
<IconButton
aria-label="up vote"
title="up vote"
icon={<ChevronUpIcon boxSize={6} />}
size="sm"
variant={myVote?.content === "+" ? "solid" : "ghost"}
isLoading={loading}
onClick={() => addVote("+")}
isDisabled={!account || !!myVote}
colorScheme={myVote ? "primary" : "gray"}
/>
{voteReactions.length > 0 && <Text my="1">{vote}</Text>}
<IconButton
aria-label="down vote"
title="down vote"
icon={<ChevronDownIcon boxSize={6} />}
size="sm"
variant={myVote?.content === "-" ? "solid" : "ghost"}
isLoading={loading}
onClick={() => addVote("-")}
isDisabled={!account || !!myVote}
/>
</Card>
);
}
function ApprovedEvent({ approval, community }: { approval: NostrEvent; community: 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 (
<Flex ref={ref} gap="2" alignItems="flex-start" overflow="hidden">
<ApprovalVoteButtons event={event} community={community} />
<EmbedEvent event={event} flex={1} />
</Flex>
);
}
export default function CommunityNewestView() {
const { community } = useOutletContext() as { community: NostrEvent };
const mods = getCommunityMods(community);
const readRelays = useReadRelayUrls(getCommunityRelays(community));
const timeline = useTimelineLoader(`${getEventUID(community)}-approved-posts`, readRelays, {
authors: unique([community.pubkey, ...mods]),
kinds: [COMMUNITY_APPROVAL_KIND],
"#a": [getEventCoordinate(community)],
});
const approvals = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<>
<IntersectionObserverProvider callback={callback}>
{approvals.map((approval) => (
<ApprovedEvent key={getEventUID(approval)} approval={approval} community={community} />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} />
</>
);
}

View File

@ -14,7 +14,7 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
import { TrustProvider } from "../../providers/trust";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { NoteMenu } from "../../components/note/note-menu";
import NoteMenu from "../../components/note/note-menu";
import { EmbedEventPointer } from "../../components/embed-event";
import { embedEmoji } from "../../components/embed-types";
import VerticalPageLayout from "../../components/vertical-page-layout";