Remove NIP-72 communities

This commit is contained in:
hzrd149 2025-01-07 10:48:44 -06:00
parent b5a7f76d9a
commit b185b0a6ed
82 changed files with 197 additions and 2313 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Remove NIP-72 communities

View File

@ -70,12 +70,6 @@ const BadgesView = lazy(() => import("./views/badges"));
const BadgesBrowseView = lazy(() => import("./views/badges/browse"));
const BadgeDetailsView = lazy(() => import("./views/badges/badge-details"));
const CommunitiesHomeView = lazy(() => import("./views/communities"));
const CommunityFindByNameView = lazy(() => import("./views/community/find-by-name"));
const CommunityView = lazy(() => import("./views/community/index"));
const CommunityPendingView = lazy(() => import("./views/community/views/pending"));
const CommunityNewestView = lazy(() => import("./views/community/views/newest"));
import RelaysView from "./views/relays";
import RelayView from "./views/relays/relay";
import BrowseRelaySetsView from "./views/relays/browse-sets";
@ -458,10 +452,6 @@ const router = createHashRouter([
{ path: "", element: <BookmarksView /> },
],
},
{
path: "communities",
children: [{ path: "", element: <CommunitiesHomeView /> }],
},
{
path: "articles",
children: [
@ -469,21 +459,6 @@ const router = createHashRouter([
{ path: ":naddr", element: <ArticleView /> },
],
},
{
path: "c/:community",
children: [
{ path: "", element: <CommunityFindByNameView /> },
{
path: ":pubkey",
element: <CommunityView />,
children: [
{ path: "", element: <CommunityNewestView /> },
{ path: "newest", element: <CommunityNewestView /> },
{ path: "pending", element: <CommunityPendingView /> },
],
},
],
},
{
path: "torrents",
children: [

View File

@ -3,7 +3,7 @@ import { verifyEvent } from "nostr-tools";
import { NostrEvent } from "../../types/nostr-event";
import { CheckIcon, VerificationFailed } from "../icons";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
function EventVerificationIcon({ event }: { event: NostrEvent }) {
const { showSignatureVerification } = useAppSettings();

View File

@ -2,7 +2,7 @@ import { PropsWithChildren, ReactNode } from "react";
import EmbedActions from "./embed-actions";
import { Link, useDisclosure } from "@chakra-ui/react";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
export default function ExpandableEmbed({
children,

View File

@ -28,7 +28,7 @@ import {
import { useRegisterSlide } from "../../lightbox-provider";
import { isImageURL } from "../../../helpers/url";
import { NostrEvent } from "../../../types/nostr-event";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import useElementTrustBlur from "../../../hooks/use-element-trust-blur";
import { buildImageProxyURL } from "../../../helpers/image";
import ExpandableEmbed from "../components/expandable-embed";

View File

@ -3,7 +3,7 @@ import { Box, useColorMode } from "@chakra-ui/react";
import { EmbedEventPointer } from "../../embed-event";
import { STEMSTR_RELAY } from "../../../helpers/nostr/stemstr";
import ExpandableEmbed from "../components/expandable-embed";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };

View File

@ -1,5 +1,5 @@
import { replaceDomain } from "../../../helpers/url";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import { renderGenericUrl } from "./common";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/reddit.js

View File

@ -2,7 +2,7 @@ import { Link } from "applesauce-content/nast";
import { renderOpenGraphUrl } from "./common";
import { replaceDomain } from "../../../helpers/url";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
export const TWITTER_DOMAINS = ["x.com", "twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];

View File

@ -2,7 +2,7 @@ import { lazy, VideoHTMLAttributes } from "react";
import styled from "@emotion/styled";
import { isStreamURL, isVideoURL } from "../../../helpers/url";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import useElementTrustBlur from "../../../hooks/use-element-trust-blur";
import ExpandableEmbed from "../components/expandable-embed";
const LiveVideoPlayer = lazy(() => import("../../live-video-player"));

View File

@ -1,6 +1,6 @@
import { AspectRatio } from "@chakra-ui/react";
import ExpandableEmbed from "../components/expandable-embed";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/youtube.js
export const YOUTUBE_DOMAINS = [

View File

@ -1,17 +1,21 @@
import { Link as RouterLink } from "react-router-dom";
import { useContext } from "react";
import { Card, CardFooter, CardHeader, CardProps, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { getTagValue } from "applesauce-core/helpers";
import UserAvatarLink from "../../user/user-avatar-link";
import UserLink from "../../user/user-link";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";
import { AppHandlerContext } from "../../../providers/route/app-handler-provider";
import useShareableEventAddress from "../../../hooks/use-shareable-event-address";
export default function EmbeddedCommunity({
community,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
const name = getCommunityName(community);
const name = getTagValue(community, "name") || getTagValue(community, "d");
const image = getTagValue(community, "image");
const naddr = useShareableEventAddress(community);
const { openAddress } = useContext(AppHandlerContext);
return (
<Card
@ -21,7 +25,7 @@ export default function EmbeddedCommunity({
gap="2"
overflow="hidden"
borderRadius="xl"
backgroundImage={getCommunityImage(community)}
backgroundImage={image}
backgroundRepeat="no-repeat"
backgroundSize="cover"
backgroundPosition="center"
@ -30,9 +34,7 @@ export default function EmbeddedCommunity({
>
<CardHeader pb="0">
<Heading size="lg">
<LinkOverlay as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
{name}
</LinkOverlay>
<LinkOverlay onClick={() => naddr && openAddress(naddr)}>{name}</LinkOverlay>
</Heading>
</CardHeader>
<CardFooter display="flex" alignItems="center" gap="2" pt="0">

View File

@ -16,7 +16,7 @@ import HoverLinkOverlay from "../../hover-link-overlay";
import singleEventService from "../../../services/single-event";
import { getSharableEventAddress } from "../../../services/relay-hints";
import localSettings from "../../../services/local-settings";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
const { showSignatureVerification } = useAppSettings();

View File

@ -15,7 +15,7 @@ import { getTorrentTitle } from "../../../helpers/nostr/torrents";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import { MouseEventHandler, useCallback } from "react";
import { nip19 } from "nostr-tools";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
export default function EmbeddedTorrentComment({
comment,

View File

@ -7,7 +7,7 @@ import { humanReadableSats } from "../../helpers/lightning";
import { LightningIcon } from "../icons";
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
import { EmbedEvent, EmbedProps } from "../embed-event";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import CustomZapAmountOptions from "./zap-options";
import UserAvatar from "../user/user-avatar";
import UserLink from "../user/user-link";

View File

@ -17,7 +17,7 @@ import UserLink from "../user/user-link";
import { ChevronDownIcon, ChevronUpIcon, CheckIcon, ErrorIcon, LightningIcon } from "../icons";
import { InvoiceModalContent } from "../invoice-modal";
import { PropsWithChildren, useEffect, useState } from "react";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
function UserCard({ children, pubkey }: PropsWithChildren & { pubkey: string }) {
return (

View File

@ -1,6 +1,6 @@
import { Button, Flex } from "@chakra-ui/react";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import { LightningIcon } from "../icons";
export default function CustomZapAmountOptions({ onSelect }: { onSelect: (value: number) => void }) {

View File

@ -223,12 +223,6 @@ export const AppearanceIcon = Colors;
export const DatabaseIcon = Database01;
export const PerformanceIcon = Speedometer03;
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,
});
/** @deprecated */
export const GhostIcon = createIcon({
displayName: "GhostIcon",
@ -255,4 +249,4 @@ export const WikiIcon = BookOpen01;
export const ArticleIcon = Edit04;
export const VideoIcon = Film02;
export const MediaIcon = Camera01
export const MediaIcon = Camera01;

View File

@ -49,9 +49,7 @@ export default function NavItems() {
else if (location.pathname.startsWith("/relays")) active = "relays";
else if (location.pathname.startsWith("/r/")) active = "relays";
else if (location.pathname.startsWith("/lists")) active = "lists";
else if (location.pathname.startsWith("/communities")) active = "communities";
else if (location.pathname.startsWith("/channels")) active = "channels";
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";
@ -83,8 +81,8 @@ export default function NavItems() {
if (apps.length < 3 && !apps.some((a) => a.id === "streams")) {
apps.push(internal.find((app) => app.id === "streams")!);
}
if (apps.length < 3 && !apps.some((a) => a.id === "communities")) {
apps.push(internal.find((app) => app.id === "communities")!);
if (apps.length < 3 && !apps.some((a) => a.id === "articles")) {
apps.push(internal.find((app) => app.id === "articles")!);
}
if (apps.length < 3 && !apps.some((a) => a.id === "channels")) {
apps.push(internal.find((app) => app.id === "channels")!);

View File

@ -9,7 +9,7 @@ import DebugEventButton from "../debug-modal/debug-event-button";
import { TrustProvider } from "../../providers/local/trust-provider";
import EventReactionButtons from "../event-reactions/event-reactions";
import AddReactionButton from "../note/timeline-note/components/add-reaction-button";
import RepostButton from "../note/timeline-note/components/repost-button";
import ShareButton from "../note/timeline-note/components/share-button";
import QuoteEventButton from "../note/quote-event-button";
import MediaPostSlides from "./media-slides";
import MediaPostContents from "./media-post-content";
@ -55,7 +55,7 @@ export default function MediaPost({ post }: { post: NostrEvent }) {
</ButtonGroup>
<ButtonGroup size="sm" variant="ghost" ml="auto">
<RepostButton event={post} />
<ShareButton event={post} />
<QuoteEventButton event={post} />
<DebugEventButton event={post} variant="ghost" ml="auto" size="sm" alignSelf="flex-start" />
</ButtonGroup>

View File

@ -1,126 +0,0 @@
import { useState } from "react";
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
SimpleGrid,
useDisclosure,
} from "@chakra-ui/react";
import { EventTemplate, NostrEvent, kinds } from "nostr-tools";
import dayjs from "dayjs";
import type { AddressPointer } from "nostr-tools/nip19";
import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../../icons";
import { getAddressPointerRelayHints, getEventRelayHint } from "../../../../services/relay-hints";
import { usePublishEvent } from "../../../../providers/global/publish-provider";
import useCurrentAccount from "../../../../hooks/use-current-account";
import useUserCommunitiesList from "../../../../hooks/use-user-communities-list";
import { createCoordinate } from "../../../../classes/batch-kind-pubkey-loader";
import { EmbedEvent } from "../../../embed-event";
function buildRepost(event: NostrEvent): EventTemplate {
const hint = getEventRelayHint(event);
const tags: NostrEvent["tags"] = [];
tags.push(["e", event.id, hint ?? ""]);
tags.push(["k", String(event.kind)]);
return {
kind: event.kind === kinds.ShortTextNote ? kinds.Repost : kinds.GenericRepost,
tags,
content: JSON.stringify(event),
created_at: dayjs().unix(),
};
}
export default function RepostModal({
event,
isOpen,
onClose,
...props
}: Omit<ModalProps, "children"> & { event: NostrEvent }) {
const account = useCurrentAccount();
const publish = usePublishEvent();
const showCommunities = useDisclosure();
const { pointers } = useUserCommunitiesList(account?.pubkey);
const [loading, setLoading] = useState(false);
const repost = async (communityPointer?: AddressPointer) => {
setLoading(true);
const draft = buildRepost(event);
if (communityPointer) {
draft.tags.push([
"a",
createCoordinate(communityPointer.kind, communityPointer.pubkey, communityPointer.identifier),
getAddressPointerRelayHints(communityPointer)[0],
]);
}
await publish("Repost", draft);
onClose();
setLoading(false);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" py="2">
Repost Note
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<EmbedEvent event={event} />
{showCommunities.isOpen && (
<SimpleGrid spacing="2" columns={{ base: 1, sm: 2 }} mt="2">
{pointers.map((pointer) => (
<Button
key={pointer.identifier + pointer.pubkey}
size="md"
variant="outline"
rightIcon={<ExternalLinkIcon />}
isLoading={loading}
onClick={() => repost(pointer)}
>
{pointer.identifier}
</Button>
))}
</SimpleGrid>
)}
</ModalBody>
<ModalFooter px="4" py="4">
<Button
variant="link"
flex={1}
size="md"
rightIcon={showCommunities.isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
py="2"
onClick={showCommunities.onToggle}
>
Repost to community
</Button>
<Button variant="ghost" size="md" mr={2} onClick={onClose} flexShrink={0}>
Cancel
</Button>
{!showCommunities.isOpen && (
<Button
colorScheme="primary"
variant="solid"
onClick={() => repost()}
size="md"
isLoading={loading}
flexShrink={0}
>
Repost
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -5,27 +5,27 @@ import { NostrEvent } from "../../../../types/nostr-event";
import { RepostIcon } from "../../../icons";
import useEventCount from "../../../../hooks/use-event-count";
import useCurrentAccount from "../../../../hooks/use-current-account";
import RepostModal from "./repost-modal";
import ShareModal from "./share-modal";
export default function RepostButton({ event }: { event: NostrEvent }) {
export default function ShareButton({ event }: { event: NostrEvent }) {
const { isOpen, onClose, onOpen } = useDisclosure();
const account = useCurrentAccount();
const hasReposted = useEventCount(
const hasShared = useEventCount(
account ? { "#e": [event.id], kinds: [kinds.Repost, kinds.GenericRepost], authors: [account.pubkey] } : undefined,
);
const repostCount = useEventCount({ "#e": [event.id], kinds: [kinds.Repost, kinds.GenericRepost] });
const ShareCount = useEventCount({ "#e": [event.id], kinds: [kinds.Repost, kinds.GenericRepost] });
return (
<>
{repostCount !== undefined && repostCount > 0 ? (
{ShareCount !== undefined && ShareCount > 0 ? (
<Button
leftIcon={<RepostIcon />}
onClick={onOpen}
title="Repost Note"
colorScheme={hasReposted ? "primary" : undefined}
colorScheme={hasShared ? "primary" : undefined}
>
{repostCount}
{ShareCount}
</Button>
) : (
<IconButton
@ -33,10 +33,10 @@ export default function RepostButton({ event }: { event: NostrEvent }) {
onClick={onOpen}
aria-label="Repost Note"
title="Repost Note"
colorScheme={hasReposted ? "primary" : undefined}
colorScheme={hasShared ? "primary" : undefined}
/>
)}
{isOpen && <RepostModal isOpen={isOpen} onClose={onClose} event={event} />}
{isOpen && <ShareModal isOpen={isOpen} onClose={onClose} event={event} />}
</>
);
}

View File

@ -0,0 +1,68 @@
import { useState } from "react";
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
} from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { useEventFactory } from "applesauce-react/hooks";
import { usePublishEvent } from "../../../../providers/global/publish-provider";
import { EmbedEvent } from "../../../embed-event";
export default function ShareModal({
event,
isOpen,
onClose,
...props
}: Omit<ModalProps, "children"> & { event: NostrEvent }) {
const publish = usePublishEvent();
const factory = useEventFactory();
const [loading, setLoading] = useState(false);
const share = async () => {
setLoading(true);
const draft = await factory.share(event);
await publish("Share", draft);
onClose();
setLoading(false);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" py="2">
Share Note
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<EmbedEvent event={event} />
</ModalBody>
<ModalFooter px="4" py="4">
<Button variant="ghost" size="md" mr="auto" onClick={onClose} flexShrink={0}>
Cancel
</Button>
<Button
colorScheme="primary"
variant="solid"
onClick={() => share()}
size="md"
isLoading={loading}
flexShrink={0}
>
Share
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -23,7 +23,7 @@ import UserLink from "../../user/user-link";
import NoteZapButton from "../note-zap-button";
import { ExpandProvider } from "../../../providers/local/expanded";
import EventVerificationIcon from "../../common-event/event-verification-icon";
import RepostButton from "./components/repost-button";
import ShareButton from "./components/share-button";
import QuoteEventButton from "../quote-event-button";
import { ReplyIcon } from "../../icons";
import NoteContentWithWarning from "./note-content-with-warning";
@ -47,7 +47,7 @@ import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import { getSharableEventAddress } from "../../../services/relay-hints";
import localSettings from "../../../services/local-settings";
import NotePublishedUsing from "../note-published-using";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
export type TimelineNoteProps = Omit<CardProps, "children"> & {
event: NostrEvent;
@ -130,7 +130,7 @@ export function TimelineNote({
{showReplyButton && (
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
)}
<RepostButton event={event} />
<ShareButton event={event} />
<QuoteEventButton event={event} />
<NoteZapButton event={event} />
</ButtonGroup>

View File

@ -1,22 +1,24 @@
import { useMemo } from "react";
import { Link as RouterLink } from "react-router-dom";
import { useContext, useMemo } from "react";
import { Link, Text, TextProps } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { nip19, NostrEvent } from "nostr-tools";
import { getEventCommunityPointer } from "../../../helpers/nostr/communities";
import { AppHandlerContext } from "../../../providers/route/app-handler-provider";
/** @deprecated remove when communities are no longer supported */
export default function NoteCommunityMetadata({
event,
...props
}: Omit<TextProps, "children"> & { event: NostrEvent }) {
const communityPointer = useMemo(() => getEventCommunityPointer(event), [event]);
const { openAddress } = useContext(AppHandlerContext);
if (!communityPointer) return null;
return (
<Text fontStyle="italic" {...props}>
Posted in{" "}
<Link as={RouterLink} to={`/c/${communityPointer.identifier}/${communityPointer.pubkey}`} color="blue.500">
<Link onClick={() => openAddress(nip19.naddrEncode(communityPointer))} color="blue.500">
{communityPointer.identifier}
</Link>{" "}
community

View File

@ -4,7 +4,7 @@ import { getContentWarning } from "applesauce-core/helpers";
import { TextNoteContents } from "./text-note-contents";
import { useExpand } from "../../../providers/local/expanded";
import ContentWarning from "../../content-warning";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
export default function NoteContentWithWarning({ event }: { event: NostrEvent }) {
const expand = useExpand();

View File

@ -1,30 +0,0 @@
import { forwardRef } from "react";
import { Select, SelectProps } from "@chakra-ui/react";
import useUserCommunitiesList from "../../hooks/use-user-communities-list";
import useCurrentAccount from "../../hooks/use-current-account";
import { getCommunityName } from "../../helpers/nostr/communities";
import { AddressPointer } from "nostr-tools/nip19";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import { getEventCoordinate } from "../../helpers/nostr/event";
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 } = useUserCommunitiesList(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

@ -38,13 +38,12 @@ import { PublishDetails } from "../../views/task-manager/publish-log/publish-det
import { TrustProvider } from "../../providers/local/trust-provider";
import MagicTextArea, { RefType } from "../magic-textarea";
import { useContextEmojis } from "../../providers/global/emoji-provider";
import CommunitySelect from "./community-select";
import ZapSplitCreator from "../../views/new/note/zap-split-creator";
import useCurrentAccount from "../../hooks/use-current-account";
import useCacheForm from "../../hooks/use-cache-form";
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../hooks/use-textarea-upload-file";
import MinePOW from "../pow/mine-pow";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import { ErrorBoundary } from "../error-boundary";
import { useFinalizeDraft, usePublishEvent } from "../../providers/global/publish-provider";
import { TextNoteContents } from "../note/timeline-note/text-note-contents";
@ -54,11 +53,9 @@ import InsertGifButton from "../gif/insert-gif-button";
import InsertImageButton from "../../views/new/note/insert-image-button";
type FormValues = {
subject: string;
content: string;
nsfw: boolean;
nsfwReason: string;
community: string;
split: Omit<ZapSplit, "percent" | "relay">[];
difficulty: number;
};
@ -66,8 +63,6 @@ type FormValues = {
export type PostModalProps = {
cacheFormKey?: string | null;
initContent?: string;
initCommunity?: string;
requireSubject?: boolean;
};
export default function PostModal({
@ -75,8 +70,6 @@ export default function PostModal({
onClose,
cacheFormKey = "new-note",
initContent = "",
initCommunity = "",
requireSubject,
}: Omit<ModalProps, "children"> & PostModalProps) {
const publish = usePublishEvent();
const finalizeDraft = useFinalizeDraft();
@ -93,11 +86,9 @@ export default function PostModal({
const [draft, setDraft] = useState<UnsignedEvent>();
const { getValues, setValue, watch, register, handleSubmit, formState, reset } = useForm<FormValues>({
defaultValues: {
subject: "",
content: initContent,
nsfw: false,
nsfwReason: "",
community: initCommunity,
split: [] as Omit<ZapSplit, "percent" | "relay">[],
difficulty: noteDifficulty || 0,
},
@ -123,10 +114,6 @@ export default function PostModal({
splits: values.split,
});
// TODO: remove when NIP-72 communities are removed
if (values.community) draft.tags.push(["a", values.community]);
if (values.subject) draft.tags.push(["subject", values.subject]);
const unsigned = await finalizeDraft(draft);
setDraft(unsigned);
@ -188,7 +175,6 @@ export default function PostModal({
return (
<>
<ModalBody display="flex" flexDirection="column" padding={["2", "2", "4"]} gap="2">
{requireSubject && <Input {...register("subject", { required: true })} isRequired placeholder="Subject" />}
<MagicTextArea
autoFocus
mb="2"
@ -242,10 +228,6 @@ export default function PostModal({
{moreOptions.isOpen && (
<Flex direction={{ base: "column", lg: "row" }} gap="4">
<Flex direction="column" gap="2" flex={1}>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && (

View File

@ -1,7 +1,6 @@
import { memo } from "react";
import { Flex, Heading, Link, Text } from "@chakra-ui/react";
import { Flex, Heading, Text } from "@chakra-ui/react";
import { kinds, nip18 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import TimelineNote from "../../note/timeline-note";
@ -13,12 +12,11 @@ import useSingleEvent from "../../../hooks/use-single-event";
import { EmbedEvent } from "../../embed-event";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
import { parseHardcodedNoteContent } from "../../../helpers/nostr/event";
import { getEventCommunityPointer } from "../../../helpers/nostr/communities";
import LoadingNostrLink from "../../loading-nostr-link";
import NoteMenu from "../../note/note-menu";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
function RepostEvent({ event }: { event: NostrEvent }) {
function ShareEvent({ event }: { event: NostrEvent }) {
const muteFilter = useUserMuteFilter();
const hardCodedNote = parseHardcodedNoteContent(event);
@ -26,8 +24,6 @@ function RepostEvent({ event }: { event: NostrEvent }) {
const loadedNote = useSingleEvent(pointer?.id, pointer?.relays);
const note = hardCodedNote || loadedNote;
const communityCoordinate = getEventCommunityPointer(event);
const ref = useEventIntersectionRef(event);
if ((note && muteFilter(note)) || !pointer) return null;
@ -41,18 +37,7 @@ function RepostEvent({ event }: { event: NostrEvent }) {
<UserLink pubkey={event.pubkey} />
</Heading>
<UserDnsIdentity pubkey={event.pubkey} onlyIcon />
<Text as="span" whiteSpace="pre">
{communityCoordinate ? `Shared to` : `Shared`}
</Text>
{communityCoordinate && (
<Link
as={RouterLink}
to={`/c/${communityCoordinate.identifier}/${communityCoordinate.pubkey}`}
fontWeight="bold"
>
{communityCoordinate.identifier}
</Link>
)}
<Text as="span">Shared</Text>
<NoteMenu event={event} size="sm" variant="link" aria-label="note options" ml="auto" />
</Flex>
{!note ? (
@ -68,4 +53,4 @@ function RepostEvent({ event }: { event: NostrEvent }) {
);
}
export default memo(RepostEvent);
export default memo(ShareEvent);

View File

@ -4,7 +4,7 @@ import { Box } from "@chakra-ui/react";
import { ErrorBoundary } from "../../error-boundary";
import ReplyNote from "./reply-note";
import RepostEvent from "./repost-event";
import ShareEvent from "./share-event";
import StreamNote from "./stream-note";
import RelayRecommendation from "./relay-recommendation";
import BadgeAwardCard from "../../../views/badges/components/badge-award-card";
@ -29,7 +29,7 @@ function TimelineItem({ event, visible, minHeight }: { event: NostrEvent; visibl
break;
case kinds.Repost:
case kinds.GenericRepost:
content = <RepostEvent event={event} />;
content = <ShareEvent event={event} />;
break;
case kinds.LiveEvent:
content = <StreamNote stream={event} />;

View File

@ -7,7 +7,7 @@ import { ProfileContent } from "applesauce-core/helpers";
import { getIdenticon } from "../../helpers/identicon";
import { safeUrl } from "../../helpers/parse";
import { getDisplayName } from "../../helpers/nostr/profile";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import useCurrentAccount from "../../hooks/use-current-account";
import { buildImageProxyURL } from "../../helpers/image";
import UserDnsIdentityIcon from "./user-dns-identity-icon";

View File

@ -4,7 +4,7 @@ import { nip19 } from "nostr-tools";
import { getDisplayName } from "../../helpers/nostr/profile";
import useUserProfile from "../../hooks/use-user-profile";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import useCurrentAccount from "../../hooks/use-current-account";
export type UserLinkProps = LinkProps & {

View File

@ -3,7 +3,7 @@ import { Text, TextProps } from "@chakra-ui/react";
import { getDisplayName } from "../../helpers/nostr/profile";
import useUserProfile from "../../hooks/use-user-profile";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
function UserName({ pubkey, ...props }: Omit<TextProps, "children"> & { pubkey: string }) {
const metadata = useUserProfile(pubkey);

View File

@ -1,6 +1,7 @@
import { ColorModeWithSystem } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
export const APP_SETTINGS_KIND = 30078;
export const APP_SETTINGS_KIND = kinds.Application;
export const APP_SETTING_IDENTIFIER = "nostrudel-settings";
export type AppSettingsV0 = {

View File

@ -39,49 +39,6 @@ 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];
if (subject) return subject;
const firstLine = event.content.match(/^[^\n\t]+/)?.[0];
if (!firstLine) return;
if (!getMatchNostrLink().test(firstLine) && !getMatchLink().test(firstLine)) return firstLine;
}
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) {
try {
getCommunityName(community);
return true;
} catch (e) {
return false;
}
}
export function buildApprovalMap(events: Iterable<NostrEvent>, mods: string[]) {
const approvals = new Map<string, NostrEvent[]>();
for (const event of events) {
if (event.kind === kinds.CommunityPostApproval && mods.includes(event.pubkey)) {
for (const tag of event.tags) {
if (isETag(tag)) {
const arr = approvals.get(tag[1]);
if (!arr) approvals.set(tag[1], [event]);
else arr.push(event);
}
}
}
}
return approvals;
}
export function getEventCommunityPointer(event: NostrEvent) {
const communityTag = event.tags.filter(isATag).find((t) => t[1].startsWith(kinds.CommunityDefinition + ":"));
return communityTag ? parseCoordinate(communityTag[1], true) : null;

View File

@ -1,8 +1,8 @@
import dayjs from "dayjs";
import { EventTemplate, NostrEvent, kinds, nip19 } from "nostr-tools";
import { EventTemplate, NostrEvent, kinds } from "nostr-tools";
import { getPointerFromTag } from "applesauce-core/helpers";
import { PTag, isATag, isDTag, isPTag, isRTag } from "../../types/nostr-event";
import { PTag, isDTag, isPTag, isRTag } from "../../types/nostr-event";
import { getEventCoordinate, replaceOrAddSimpleTag } from "./event";
import { getRelayVariations, safeRelayUrls } from "../relay";
import { isAddressPointerInList, isEventPointerInList, isProfilePointerInList } from "applesauce-lists/helpers";

View File

@ -1,9 +0,0 @@
import { kinds } from "nostr-tools";
import { getEventCoordinate } from "../helpers/nostr/event";
import { NostrEvent } from "../types/nostr-event";
import useEventCount from "./use-event-count";
export default function useCountCommunityMembers(community: NostrEvent) {
return useEventCount({ "#a": [getEventCoordinate(community)], kinds: [kinds.CommunitiesList] });
}

View File

@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import useAppSettings from "./use-app-settings";
import useAppSettings from "./use-user-app-settings";
export default function useWordMuteFilter() {
const { mutedWords } = useAppSettings();

View File

@ -1,7 +1,7 @@
import { useAsync } from "react-use";
import { fetchWithProxy } from "../helpers/request";
import type { OgObjectInteral } from "../lib/open-graph-scraper/types";
import useAppSettings from "./use-app-settings";
import useAppSettings from "./use-user-app-settings";
const pageExtensions = [".html", ".php", "htm"];

View File

@ -1,7 +1,7 @@
import { useColorMode } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom";
import { useEffect } from "react";
import useAppSettings from "./use-app-settings";
import useAppSettings from "./use-user-app-settings";
export default function useSetColorMode() {
const { setColorMode } = useColorMode();

View File

@ -5,7 +5,7 @@ import { nostrBuildUploadImage } from "../helpers/media-upload/nostr-build";
import { RefType } from "../components/magic-textarea";
import { useSigningContext } from "../providers/global/signing-provider";
import { UseFormGetValues, UseFormSetValue } from "react-hook-form";
import useAppSettings from "./use-app-settings";
import useAppSettings from "./use-user-app-settings";
import useUsersMediaServers from "./use-user-media-servers";
import { simpleMultiServerUpload } from "../helpers/media-upload/blossom";
import useCurrentAccount from "./use-current-account";

View File

@ -19,12 +19,19 @@ function buildAppSettingsEvent(settings: Partial<AppSettings>): EventTemplate {
};
}
export function useUserAppSettings(pubkey: string) {
useReplaceableEvent({ kind: APP_SETTINGS_KIND, pubkey, identifier: APP_SETTING_IDENTIFIER });
return useStoreQuery(AppSettingsQuery, [pubkey]);
}
export default function useAppSettings() {
const account = useCurrentAccount();
const publish = usePublishEvent();
// load synced settings
useReplaceableEvent(account?.pubkey && { kind: APP_SETTINGS_KIND, pubkey: account.pubkey });
useReplaceableEvent(
account?.pubkey && { kind: APP_SETTINGS_KIND, pubkey: account.pubkey, identifier: APP_SETTING_IDENTIFIER },
);
const localSettings = account?.localSettings;
const syncedSettings = useStoreQuery(AppSettingsQuery, account && [account.pubkey]);

View File

@ -1,41 +0,0 @@
import { kinds } from "nostr-tools";
import { getAddressPointersFromList } from "applesauce-lists/helpers";
import { SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER } from "../helpers/nostr/communities";
import { RequestOptions } from "../services/replaceable-events";
import useCurrentAccount from "./use-current-account";
import useReplaceableEvent from "./use-replaceable-event";
export default function useUserCommunitiesList(pubkey?: string, relays?: Iterable<string>, opts?: RequestOptions) {
const account = useCurrentAccount();
const key = pubkey ?? account?.pubkey;
// TODO: remove at some future date when apps have transitioned to using k:10004 for communities
// https://github.com/nostr-protocol/nips/pull/880
/** @deprecated */
const oldList = useReplaceableEvent(
key
? {
kind: kinds.Genericlists,
identifier: SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER,
pubkey: key,
}
: undefined,
[],
opts,
);
const list = useReplaceableEvent(key ? { kind: kinds.CommunitiesList, pubkey: key } : undefined, relays, opts);
let useList = list || oldList;
// if both exist, use the newest one
if (list && oldList) {
useList = list.created_at > oldList.created_at ? list : oldList;
}
const pointers = useList
? getAddressPointersFromList(useList).filter((cord) => cord.kind === kinds.CommunityDefinition)
: [];
return { list: useList, pointers };
}

View File

@ -4,7 +4,7 @@ import { QueryStoreProvider } from "applesauce-react/providers";
import { SigningProvider } from "./signing-provider";
import buildTheme from "../../theme";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import NotificationsProvider from "./notifications-provider";
import { UserEmojiProvider } from "./emoji-provider";
import BreakpointProvider from "./breakpoint-provider";

View File

@ -1,7 +1,7 @@
import React, { useCallback, useContext, useState } from "react";
import InvoiceModal from "../../components/invoice-modal";
import createDefer, { Deferred } from "../../classes/deferred";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
export type InvoiceModalContext = {
requestPay: (invoice: string) => Promise<void>;

View File

@ -1,86 +0,0 @@
import { memo } from "react";
import { nip19 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import {
Card,
CardFooter,
CardHeader,
CardProps,
Heading,
LinkBox,
LinkOverlay,
Tag,
TagLabel,
TagLeftIcon,
Text,
} from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";
import useCountCommunityMembers from "../../../hooks/use-count-community-members";
import { humanReadableSats } from "../../../helpers/lightning";
import User01 from "../../../components/icons/user-01";
import useReplaceableEvent from "../../../hooks/use-replaceable-event";
import { AddressPointer } from "nostr-tools/nip19";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & { community: NostrEvent }) {
const ref = useEventIntersectionRef(community);
const name = getCommunityName(community);
const countMembers = useCountCommunityMembers(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"
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} />
</CardBody> */}
<CardFooter display="flex" alignItems="center" gap="2" pt="0">
<UserAvatarLink pubkey={community.pubkey} size="sm" />
<Text>by</Text>
<UserLink pubkey={community.pubkey} />
{countMembers !== undefined && countMembers > 0 && (
<Tag variant="solid" ml="auto" alignSelf="flex-end" textShadow="none">
<TagLeftIcon as={User01} boxSize={4} />
<TagLabel>{humanReadableSats(countMembers)}</TagLabel>
</Tag>
)}
{/* {notesInLastMonth !== undefined && <Text ml="auto">{notesInLastMonth} Posts in the past month</Text>} */}
</CardFooter>
</Card>
);
}
export function PointerCommunityCard({ pointer, ...props }: Omit<CardProps, "children"> & { pointer: AddressPointer }) {
const community = useReplaceableEvent(pointer);
if (!community) return <span>Loading {pointer.identifier}</span>;
return <CommunityCard community={community} {...props} />;
}
export default memo(CommunityCard);

View File

@ -1,350 +0,0 @@
import { useCallback, useState } from "react";
import {
Box,
Button,
Flex,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
IconButton,
IconButtonProps,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
Text,
Textarea,
useToast,
} from "@chakra-ui/react";
import { SubmitHandler, useForm } from "react-hook-form";
import useCurrentAccount from "../../../hooks/use-current-account";
import UserAvatar from "../../../components/user/user-avatar";
import UserLink from "../../../components/user/user-link";
import { TrashIcon } from "../../../components/icons";
import Upload01 from "../../../components/icons/upload-01";
import { nostrBuildUploadImage } from "../../../helpers/media-upload/nostr-build";
import { useSigningContext } from "../../../providers/global/signing-provider";
import { RelayUrlInput } from "../../../components/relay-url-input";
import { RelayFavicon } from "../../../components/relay-favicon";
import UserAutocomplete from "../../../components/user-autocomplete";
import { normalizeToHexPubkey } from "../../../helpers/nip19";
import { safeUrl } from "../../../helpers/parse";
import { safeRelayUrl } from "../../../helpers/relay";
function RemoveButton({ ...props }: IconButtonProps) {
return <IconButton icon={<TrashIcon />} size="sm" colorScheme="red" variant="ghost" ml="auto" {...props} />;
}
export type FormValues = {
name: string;
banner: string;
description: string;
rules: string;
mods: string[];
relays: string[];
links: ([string] | [string, string])[];
// ranking: string;
};
export default function CommunityCreateModal({
isOpen,
onClose,
onSubmit,
defaultValues,
isUpdate,
...props
}: Omit<ModalProps, "children"> & {
onSubmit: SubmitHandler<FormValues>;
defaultValues?: FormValues;
isUpdate?: boolean;
}) {
const toast = useToast();
const account = useCurrentAccount();
const { requestSignature } = useSigningContext();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
watch,
getValues,
setValue,
} = useForm<FormValues>({
mode: "all",
defaultValues: defaultValues || {
name: "",
banner: "",
description: "",
rules: "",
mods: account ? [account.pubkey] : [],
relays: [],
links: [],
// ranking: "votes",
},
});
watch("mods");
// watch("ranking");
watch("banner");
watch("links");
watch("relays");
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
try {
if (!(file.type.includes("image") || file.type.includes("video") || file.type.includes("audio")))
throw new Error("Unsupported file type");
setUploading(true);
const response = await nostrBuildUploadImage(file, requestSignature);
const imageUrl = response.url;
setValue("banner", imageUrl, { shouldDirty: true, shouldValidate: true });
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
},
[setValue, getValues, requestSignature, toast],
);
const [modInput, setModInput] = useState("");
const addMod = () => {
if (!modInput) return;
const pubkey = normalizeToHexPubkey(modInput);
if (pubkey) {
setValue("mods", getValues("mods").concat(pubkey));
}
setModInput("");
};
const removeMod = (pubkey: string) => {
setValue(
"mods",
getValues("mods").filter((p) => p !== pubkey),
);
};
const [relayInput, setRelayInput] = useState("");
const addRelay = () => {
if (!relayInput) return;
const url = safeRelayUrl(relayInput);
if (url) {
setValue("relays", getValues("relays").concat(url));
}
setRelayInput("");
};
const removeRelay = (url: string) => {
setValue(
"relays",
getValues("relays").filter((r) => r !== url),
);
};
const [linkInput, setLinkInput] = useState("");
const [linkName, setLinkName] = useState("");
const addLink = () => {
if (!linkInput) return;
const url = safeUrl(linkInput);
if (url) {
setValue("links", [...getValues("links"), linkName ? [url, linkName] : [url]]);
}
setLinkInput("");
setLinkName("");
};
const removeLink = (url: string) => {
setValue(
"links",
getValues("links").filter(([r]) => r !== url),
);
};
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"
placeholder="more-cat-pictures"
/>
<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.banner}>
<FormLabel>Banner</FormLabel>
{getValues().banner && (
<Box
backgroundImage={getValues().banner}
backgroundRepeat="no-repeat"
backgroundPosition="center"
backgroundSize="cover"
aspectRatio={3 / 1}
mb="2"
borderRadius="lg"
/>
)}
<Flex gap="2">
<Input
type="url"
{...register("banner")}
autoComplete="off"
placeholder="https://example.com/banner.png"
/>
<Input
id="banner-upload"
type="file"
accept="image/*"
display="none"
onChange={(e) => {
const img = e.target.files?.[0];
if (img) uploadFile(img);
}}
/>
<IconButton
as="label"
htmlFor="banner-upload"
icon={<Upload01 />}
aria-label="Upload Image"
cursor="pointer"
tabIndex={0}
isLoading={uploading}
/>
</Flex>
{errors.banner?.message && <FormErrorMessage>{errors.banner?.message}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={!!errors.rules}>
<FormLabel>Rules and Guidelines</FormLabel>
<Textarea {...register("rules")} autoComplete="off" placeholder="don't be a jerk" />
<FormHelperText>Rules and posting guidelines</FormHelperText>
{errors.rules?.message && <FormErrorMessage>{errors.rules?.message}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={!!errors.mods}>
<FormLabel>Moderators</FormLabel>
<Flex direction="column" gap="2" pb="2">
{getValues().mods.map((pubkey) => (
<Flex gap="2" alignItems="center" key={pubkey}>
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" />
<RemoveButton
aria-label={`Remove moderator`}
title={`Remove moderator`}
onClick={() => removeMod(pubkey)}
/>
</Flex>
))}
</Flex>
<Flex gap="2">
<UserAutocomplete value={modInput} onChange={(e) => setModInput(e.target.value)} />
<Button isDisabled={!modInput} onClick={addMod}>
Add
</Button>
</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 way posts are ranked when viewing the community</FormHelperText>
{errors.ranking?.message && <FormErrorMessage>{errors.ranking?.message}</FormErrorMessage>}
</FormControl> */}
<FormControl isInvalid={!!errors.mods}>
<FormLabel>Relays</FormLabel>
<FormHelperText>A Short list of recommended relays for the community</FormHelperText>
<Flex direction="column" gap="2" py="2">
{getValues().relays.map((url) => (
<Flex key={url} alignItems="center" gap="2">
<RelayFavicon relay={url} size="sm" />
<Text fontWeight="bold" isTruncated>
{url}
</Text>
<RemoveButton aria-label={`Remove ${url}`} title={`Remove ${url}`} onClick={() => removeRelay(url)} />
</Flex>
))}
</Flex>
<Flex gap="2">
<RelayUrlInput value={relayInput} onChange={(e) => setRelayInput(e.target.value)} />
<Button isDisabled={!relayInput} onClick={addRelay}>
Add
</Button>
</Flex>
</FormControl>
<FormControl isInvalid={!!errors.mods}>
<FormLabel>Links</FormLabel>
<FormHelperText>A few helpful resources for the community</FormHelperText>
<Flex direction="column" mt="2">
{getValues().links.map(([link, name]) => (
<Flex key={link}>
<Link href={link}>{name || link}</Link>
<RemoveButton aria-label="Remove Link" title="Remove Link" onClick={() => removeLink(link)} />
</Flex>
))}
</Flex>
<Flex gap="2">
<Input
type="url"
placeholder="https://example.com/useful-resources.html"
value={linkInput}
onChange={(e) => setLinkInput(e.target.value)}
/>
<Input placeholder="title" value={linkName} onChange={(e) => setLinkName(e.target.value)} />
<Button isDisabled={!linkInput} onClick={addLink} flexShrink={0}>
Add
</Button>
</Flex>
</FormControl>
</ModalBody>
<ModalFooter p="4" display="flex" gap="2">
<Button onClick={onClose}>Cancel</Button>
<Button colorScheme="primary" type="submit" isLoading={isSubmitting}>
{isUpdate ? "Update Community" : "Create Community"}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -1,40 +0,0 @@
import { useState } from "react";
import { Box, BoxProps, Button } from "@chakra-ui/react";
import { useRenderedContent } from "applesauce-react/hooks";
import { NostrEvent } from "../../../types/nostr-event";
import { getCommunityDescription } from "../../../helpers/nostr/communities";
import { components } from "../../../components/content";
import { renderGenericUrl } from "../../../components/content/links";
const linkRenderers = [renderGenericUrl];
const CommunityDescriptionSymbol = Symbol.for("community-description-content");
export default function CommunityDescription({
community,
maxLength,
showExpand,
...props
}: Omit<BoxProps, "children"> & { community: NostrEvent; maxLength?: number; showExpand?: boolean }) {
const description = getCommunityDescription(community);
const [showAll, setShowAll] = useState(false);
const content = useRenderedContent(description, components, {
maxLength: showAll ? undefined : maxLength,
linkRenderers,
cacheKey: CommunityDescriptionSymbol,
});
return (
<>
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
{maxLength !== undefined && showExpand && !showAll && (description?.length ?? 0) > maxLength && (
<Button variant="link" onClick={() => setShowAll(true)}>
Show More
</Button>
)}
</>
);
}

View File

@ -1,51 +0,0 @@
import { useCallback } from "react";
import dayjs from "dayjs";
import { Button, ButtonProps } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { DraftNostrEvent, NostrEvent, isDTag } from "../../../types/nostr-event";
import useUserCommunitiesList from "../../../hooks/use-user-communities-list";
import useCurrentAccount from "../../../hooks/use-current-account";
import { getCommunityName } from "../../../helpers/nostr/communities";
import { listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists";
import { getEventCoordinate } from "../../../helpers/nostr/event";
import { usePublishEvent } from "../../../providers/global/publish-provider";
export default function CommunityJoinButton({
community,
...props
}: Omit<ButtonProps, "children"> & { community: NostrEvent }) {
const publish = usePublishEvent();
const account = useCurrentAccount();
const { list, pointers } = useUserCommunitiesList(account?.pubkey);
const isSubscribed = pointers.find(
(cord) => cord.identifier === getCommunityName(community) && cord.pubkey === community.pubkey,
);
const handleClick = useCallback(async () => {
const favList = {
kind: kinds.CommunitiesList,
content: list?.content ?? "",
created_at: dayjs().unix(),
tags: list?.tags.filter((t) => !isDTag(t)) ?? [],
};
let draft: DraftNostrEvent;
if (isSubscribed) draft = listRemoveCoordinate(favList, getEventCoordinate(community));
else draft = listAddCoordinate(favList, getEventCoordinate(community));
await publish(isSubscribed ? "Unsubscribe" : "Subscribe", draft);
}, [isSubscribed, list, community, publish]);
return (
<Button
onClick={handleClick}
variant={isSubscribed ? "outline" : "solid"}
colorScheme={isSubscribed ? "red" : "green"}
{...props}
>
{isSubscribed ? "Leave" : "Join"}
</Button>
);
}

View File

@ -1,20 +0,0 @@
import { AvatarGroup, AvatarGroupProps } from "@chakra-ui/react";
import UserAvatarLink from "../../../components/user/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 key={pubkey} pubkey={pubkey} />
))}
</AvatarGroup>
);
}

View File

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

View File

@ -1,196 +0,0 @@
import { useMemo } from "react";
import {
Button,
ButtonGroup,
Center,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
Flex,
Heading,
Link,
Switch,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { kinds } from "nostr-tools";
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 useUserCommunitiesList from "../../hooks/use-user-communities-list";
import useCurrentAccount from "../../hooks/use-current-account";
import CommunityCard from "./components/community-card";
import CommunityCreateModal, { FormValues } from "./components/community-create-modal";
import { DraftNostrEvent } from "../../types/nostr-event";
import { buildApprovalMap, getCommunityMods, getCommunityName } from "../../helpers/nostr/communities";
import { getImageSize } from "../../helpers/image";
import { useReadRelays } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useUserMuteFilter from "../../hooks/use-user-mute-filter";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useReplaceableEvents from "../../hooks/use-replaceable-events";
import { getEventCoordinate, sortByDate } from "../../helpers/nostr/event";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import ApprovedEvent from "../community/components/community-approved-post";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import { usePublishEvent } from "../../providers/global/publish-provider";
import { createCoordinate } from "../../classes/batch-kind-pubkey-loader";
function CommunitiesHomePage() {
const publish = usePublishEvent();
const navigate = useNavigate();
const account = useCurrentAccount()!;
const createModal = useDisclosure();
const readRelays = useReadRelays();
const { pointers: communityCoordinates } = useUserCommunitiesList(account.pubkey, readRelays, {
alwaysRequest: true,
});
const communities = useReplaceableEvents(communityCoordinates, readRelays).sort(sortByDate);
const createCommunity = async (values: FormValues) => {
const draft: DraftNostrEvent = {
kind: kinds.CommunityDefinition,
created_at: dayjs().unix(),
content: "",
tags: [["d", values.name]],
};
if (values.description) draft.tags.push(["description", values.description]);
if (values.banner) {
try {
const size = await getImageSize(values.banner);
draft.tags.push(["image", values.banner, `${size.width}x${size.height}`]);
} catch (e) {
draft.tags.push(["image", values.banner]);
}
}
for (const pubkey of values.mods) draft.tags.push(["p", pubkey, "", "moderator"]);
for (const url of values.relays) draft.tags.push(["relay", url]);
for (const [url, name] of values.links) draft.tags.push(name ? ["r", url, name] : ["r", url]);
// if (values.ranking) draft.tags.push(["rank_mode", values.ranking]);
const pub = await publish("Create Community", draft, values.relays, false);
if (pub) navigate(`/c/${getCommunityName(pub.event)}/${pub.event.pubkey}`);
};
const { loader, timeline: events } = useTimelineLoader(
`all-communities-timeline`,
readRelays,
communityCoordinates.length > 0
? {
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.GenericRepost, kinds.CommunityPostApproval],
"#a": communityCoordinates.map((p) => createCoordinate(p.kind, p.pubkey, p.identifier)),
}
: undefined,
);
const showUnapproved = useDisclosure();
const muteFilter = useUserMuteFilter();
const mods = useMemo(() => {
const set = new Set<string>();
for (const community of communities) {
for (const pubkey of getCommunityMods(community)) {
set.add(pubkey);
}
}
return Array.from(set);
}, [communities]);
const approvalMap = buildApprovalMap(events, mods);
const approved = events
.filter((e) => e.kind !== kinds.CommunityPostApproval && (showUnapproved.isOpen ? true : approvalMap.has(e.id)))
.map((event) => ({ event, approvals: approvalMap.get(event.id) }))
.filter((e) => !muteFilter(e.event));
const callback = useTimelineCurserIntersectionCallback(loader);
const communityDrawer = useDisclosure();
return (
<>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<Button as={RouterLink} to="/communities/explore">
Explore
</Button>
<ButtonGroup ml="auto">
<Button onClick={createModal.onOpen}>Create</Button>
<Button onClick={communityDrawer.onOpen} hideFrom="xl">
Joined
</Button>
</ButtonGroup>
</Flex>
{communities.length > 0 ? (
<Flex gap="4" overflow="hidden">
<Flex direction="column" gap="2" flex={1} overflow="hidden">
<Flex alignItems="center" gap="4">
<Heading size="lg">Latest Posts</Heading>
<Switch isChecked={showUnapproved.isOpen} onChange={showUnapproved.onToggle}>
Show Unapproved
</Switch>
</Flex>
<IntersectionObserverProvider callback={callback}>
{approved.map(({ event, approvals }) => (
<ApprovedEvent key={event.id} event={event} approvals={approvals ?? []} showCommunity />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={loader} />
</Flex>
<Flex gap="2" direction="column" w="md" flexShrink={0} hideBelow="xl">
<Heading size="md">Joined Communities</Heading>
{communities.map((community) => (
<ErrorBoundary key={getEventCoordinate(community)} event={community}>
<CommunityCard community={community} />
</ErrorBoundary>
))}
</Flex>
</Flex>
) : (
<Center py="20" 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} />
)}
<Drawer isOpen={communityDrawer.isOpen} placement="right" onClose={communityDrawer.onClose} size="lg">
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader p="4">Joined Communities</DrawerHeader>
<DrawerBody display="flex" flexDirection="column" gap="2" px="4" pt="0" pb="8" overflowY="auto">
{communities.map((community) => (
<ErrorBoundary key={getEventCoordinate(community)} event={community}>
<CommunityCard community={community} flexShrink={0} />
</ErrorBoundary>
))}
</DrawerBody>
</DrawerContent>
</Drawer>
</>
);
}
export default function CommunitiesHomeView() {
const account = useCurrentAccount();
return account ? <CommunitiesHomePage /> : <Navigate to="/communities/explore" />;
}

View File

@ -1,166 +0,0 @@
import { useContext } from "react";
import { Button, ButtonGroup, Divider, Flex, Heading, Text, useDisclosure } from "@chakra-ui/react";
import { Outlet, Link as RouterLink, useLocation } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { kinds, nip19 } from "nostr-tools";
import {
getCommunityRelays,
getCommunityImage,
getCommunityName,
getCommunityMods,
buildApprovalMap,
} from "../../helpers/nostr/communities";
import { NostrEvent } from "../../types/nostr-event";
import VerticalPageLayout from "../../components/vertical-page-layout";
import UserAvatarLink from "../../components/user/user-avatar-link";
import UserLink from "../../components/user/user-link";
import { AdditionalRelayProvider } from "../../providers/local/additional-relay-context";
import TrendUp01 from "../../components/icons/trend-up-01";
import Clock from "../../components/icons/clock";
import Hourglass03 from "../../components/icons/hourglass-03";
import VerticalCommunityDetails from "./components/vertical-community-details";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import HorizontalCommunityDetails from "./components/horizonal-community-details";
import { useReadRelays } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { getEventCoordinate, getEventUID } from "../../helpers/nostr/event";
import { WritingIcon } from "../../components/icons";
import { PostModalContext } from "../../providers/route/post-modal-provider";
import CommunityEditModal from "./components/community-edit-modal";
import TimelineLoader from "../../classes/timeline-loader";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
function getCommunityPath(community: NostrEvent) {
return `/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`;
}
export type RouterContext = { community: NostrEvent; timeline: TimelineLoader };
export default function CommunityHomePage({ community }: { community: NostrEvent }) {
const muteFilter = useClientSideMuteFilter();
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 });
const communityRelays = getCommunityRelays(community);
const readRelays = useReadRelays(communityRelays);
const { loader } = useTimelineLoader(`${getEventUID(community)}-timeline`, readRelays, {
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.GenericRepost, kinds.CommunityPostApproval],
"#a": [communityCoordinate],
});
// get pending notes
const events = useObservable(loader.timeline) ?? [];
const mods = getCommunityMods(community);
const approvals = buildApprovalMap(events, mods);
const pending = events.filter(
(e) => e.kind !== kinds.CommunityPostApproval && !approvals.has(e.id) && !muteFilter(e),
);
let active = "newest";
if (location.pathname.endsWith("/newest")) active = "newest";
if (location.pathname.endsWith("/pending")) active = "pending";
if (location.pathname.endsWith("/trending")) active = "trending";
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} onEditClick={editModal.onOpen} />
)}
<Flex gap="4" alignItems="flex-start" overflow="hidden">
<Flex direction="column" gap="4" flex={1} overflow="hidden" minH="full">
<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 />}
as={RouterLink}
to={getCommunityPath(community) + "/trending"}
colorScheme={active === "trending" ? "primary" : "gray"}
replace
>
Trending
</Button>
<Button
leftIcon={<Clock />}
as={RouterLink}
to={getCommunityPath(community) + "/newest"}
colorScheme={active === "newest" ? "primary" : "gray"}
replace
>
New
</Button>
<Button
leftIcon={<Hourglass03 />}
as={RouterLink}
to={getCommunityPath(community) + "/pending"}
colorScheme={active == "pending" ? "primary" : "gray"}
replace
>
Pending ({pending.length})
</Button>
</ButtonGroup>
<Outlet context={{ community, timeline: loader } satisfies RouterContext} />
</Flex>
{!verticalLayout && (
<VerticalCommunityDetails
community={community}
w="full"
maxW="xs"
flexShrink={0}
onEditClick={editModal.onOpen}
/>
)}
</Flex>
</VerticalPageLayout>
</AdditionalRelayProvider>
{editModal.isOpen && (
<CommunityEditModal isOpen={editModal.isOpen} onClose={editModal.onClose} community={community} />
)}
</>
);
}

View File

@ -1,21 +0,0 @@
import { memo } from "react";
import { Card, Flex } from "@chakra-ui/react";
import EventVoteButtons from "../../../components/reactions/event-vote-buttions";
import CommunityPost from "./community-post";
import { NostrEvent } from "../../../types/nostr-event";
const ApprovedEvent = memo(
({ event, approvals, showCommunity }: { event: NostrEvent; approvals: NostrEvent[]; showCommunity?: boolean }) => {
return (
<Flex gap="2" alignItems="flex-start">
<Card borderRadius="lg">
<EventVoteButtons event={event} flexShrink={0} />
</Card>
<CommunityPost event={event} approvals={approvals} flex={1} showCommunity={showCommunity} />
</Flex>
);
},
);
export default ApprovedEvent;

View File

@ -1,79 +0,0 @@
import { useMemo } from "react";
import { ModalProps } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import {
getCommunityDescription,
getCommunityImage,
getCommunityLinks,
getCommunityMods,
getCommunityName,
getCommunityRelays,
getCommunityRules,
} from "../../../helpers/nostr/communities";
import CommunityCreateModal, { FormValues } from "../../communities/components/community-create-modal";
import { getImageSize } from "../../../helpers/image";
import { usePublishEvent } from "../../../providers/global/publish-provider";
export default function CommunityEditModal({
isOpen,
onClose,
community,
...props
}: Omit<ModalProps, "children"> & { community: NostrEvent }) {
const publish = usePublishEvent();
const defaultValues = useMemo<FormValues>(
() => ({
name: getCommunityName(community),
description: getCommunityDescription(community) || "",
banner: getCommunityImage(community) || "",
rules: getCommunityRules(community) || "",
mods: getCommunityMods(community) || [],
relays: getCommunityRelays(community) || [],
links: getCommunityLinks(community) || [],
// ranking: getCommunityRanking(community) || "votes",
}),
[community],
);
const updateCommunity = async (values: FormValues) => {
const draft: DraftNostrEvent = {
kind: kinds.CommunityDefinition,
created_at: dayjs().unix(),
content: "",
tags: [["d", getCommunityName(community)]],
};
if (values.description) draft.tags.push(["description", values.description]);
if (values.banner) {
try {
const size = await getImageSize(values.banner);
draft.tags.push(["image", values.banner, `${size.width}x${size.height}`]);
} catch (e) {
draft.tags.push(["image", values.banner]);
}
}
for (const pubkey of values.mods) draft.tags.push(["p", pubkey, "", "moderator"]);
for (const url of values.relays) draft.tags.push(["relay", url]);
for (const [url, name] of values.links) draft.tags.push(name ? ["r", url, name] : ["r", url]);
// if (values.ranking) draft.tags.push(["rank_mode", values.ranking]);
const pub = await publish("Update Community", draft, values.relays);
if (pub) onClose();
};
return (
<CommunityCreateModal
isOpen={isOpen}
onClose={onClose}
onSubmit={updateCommunity}
defaultValues={defaultValues}
isUpdate
{...props}
/>
);
}

View File

@ -1,84 +0,0 @@
import {
Button,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
SimpleGrid,
} from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { NostrEvent } from "../../../types/nostr-event";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import { useReadRelays } from "../../../hooks/use-client-relays";
import { getCommunityRelays } from "../../../helpers/nostr/communities";
import { getEventCoordinate } from "../../../helpers/nostr/event";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../../components/timeline/timeline-action-and-status";
import UserLink from "../../../components/user/user-link";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import UserAvatarLink from "../../../components/user/user-avatar-link";
function UserCard({ pubkey }: { pubkey: string }) {
return (
<Flex overflow="hidden" gap="4" alignItems="center">
<UserAvatarLink pubkey={pubkey} />
<Flex direction="column" flex={1} overflow="hidden">
<UserLink pubkey={pubkey} fontWeight="bold" />
<UserDnsIdentity pubkey={pubkey} />
</Flex>
</Flex>
);
}
export default function CommunityMembersModal({
community,
onClose,
...props
}: Omit<ModalProps, "children"> & { community: NostrEvent }) {
const communityCoordinate = getEventCoordinate(community);
const readRelays = useReadRelays(getCommunityRelays(community));
const { loader, timeline: lists } = useTimelineLoader(`${communityCoordinate}-members`, readRelays, [
{ "#a": [communityCoordinate], kinds: [kinds.CommunitiesList] },
]);
const callback = useTimelineCurserIntersectionCallback(loader);
const listsByPubkey: Record<string, NostrEvent> = {};
if (lists) {
for (const list of lists) {
if (!listsByPubkey[list.pubkey] || listsByPubkey[list.pubkey].created_at < list.created_at) {
listsByPubkey[list.pubkey] = list;
}
}
}
return (
<IntersectionObserverProvider callback={callback}>
<Modal onClose={onClose} size="4xl" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader px="4" pt="4" pb="0">
Community Members
</ModalHeader>
<ModalCloseButton />
<ModalBody p="4">
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="4">
{lists?.map((list) => <UserCard key={list.id} pubkey={list.pubkey} />)}
</SimpleGrid>
<TimelineActionAndStatus timeline={loader} />
</ModalBody>
<ModalFooter px="4" pt="0" pb="4">
<Button onClick={onClose}>Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
</IntersectionObserverProvider>
);
}

View File

@ -1,32 +0,0 @@
import { MenuItem } from "@chakra-ui/react";
import { DotsMenuButton, MenuIconButtonProps } from "../../../components/dots-menu-button";
import { NostrEvent } from "../../../types/nostr-event";
import useCurrentAccount from "../../../hooks/use-current-account";
import PencilLine from "../../../components/icons/pencil-line";
import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app";
import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code";
import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu-item";
export default function CommunityMenu({
community,
onEditClick,
...props
}: Omit<MenuIconButtonProps, "children"> & { community: NostrEvent; onEditClick?: () => void }) {
const account = useCurrentAccount();
return (
<>
<DotsMenuButton {...props}>
<OpenInAppMenuItem event={community} />
<CopyEmbedCodeMenuItem event={community} />
{account?.pubkey === community.pubkey && onEditClick && (
<MenuItem onClick={onEditClick} icon={<PencilLine />}>
Edit Community
</MenuItem>
)}
<DebugEventMenuItem event={community} />
</DotsMenuButton>
</>
);
}

View File

@ -1,39 +0,0 @@
import { MenuItem, useToast } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { DotsMenuButton, MenuIconButtonProps } from "../../../components/dots-menu-button";
import { NostrEvent } from "../../../types/nostr-event";
import { CopyToClipboardIcon } from "../../../components/icons";
import ShareLinkMenuItem from "../../../components/common-menu-items/share-link";
import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app";
import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event";
import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu-item";
export default function CommunityPostMenu({
event,
approvals,
...props
}: Omit<MenuIconButtonProps, "children"> & { event: NostrEvent; approvals: NostrEvent[] }) {
const toast = useToast();
return (
<>
<DotsMenuButton {...props}>
<OpenInAppMenuItem event={event} />
<ShareLinkMenuItem event={event} />
<MenuItem
onClick={() => {
const text = nip19.noteEncode(event.id);
if (navigator.clipboard) navigator.clipboard.writeText(text);
else toast({ description: text, isClosable: true, duration: null });
}}
icon={<CopyToClipboardIcon />}
>
Copy Note ID
</MenuItem>
<DeleteEventMenuItem event={event} label="Delete Post" />
<DebugEventMenuItem event={event} />
</DotsMenuButton>
</>
);
}

View File

@ -1,184 +0,0 @@
import { MouseEventHandler, useCallback } from "react";
import {
AvatarGroup,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Link,
LinkBox,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import dayjs from "dayjs";
import { kinds } from "nostr-tools";
import { NostrEvent, isETag } from "../../../types/nostr-event";
import { getEventCommunityPointer, getPostSubject } from "../../../helpers/nostr/communities";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { CompactNoteContent } from "../../../components/compact-note-content";
import { parseHardcodedNoteContent } from "../../../helpers/nostr/event";
import UserLink from "../../../components/user/user-link";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
import { useReadRelays } from "../../../hooks/use-client-relays";
import useSingleEvent from "../../../hooks/use-single-event";
import CommunityPostMenu from "./community-post-menu";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import useShareableEventAddress from "../../../hooks/use-shareable-event-address";
export function ApprovalIcon({ approval }: { approval: NostrEvent }) {
const ref = useEventIntersectionRef<HTMLAnchorElement>(approval);
return <UserAvatarLink pubkey={approval.pubkey} ref={ref} size="xs" />;
}
export type CommunityPostPropTypes = {
event: NostrEvent;
approvals: NostrEvent[];
showCommunity?: boolean;
};
function PostSubject({ event }: { event: NostrEvent }) {
const subject = getPostSubject(event);
const address = useShareableEventAddress(event);
const navigate = useNavigateInDrawer();
const to = `/n/${address}`;
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
navigate(to);
},
[navigate, to],
);
return subject ? (
<CardHeader px="2" pt="4" pb="0" overflow="hidden">
<Heading size="md" overflow="hidden" isTruncated>
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick}>
{getPostSubject(event)}
</HoverLinkOverlay>
</Heading>
</CardHeader>
) : (
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick} />
);
}
function Approvals({ approvals }: { approvals: NostrEvent[] }) {
return (
<>
<Text fontSize="sm">Approved by</Text>
<AvatarGroup>
{approvals.map((approval) => (
<ApprovalIcon key={approval.id} approval={approval} />
))}
</AvatarGroup>
</>
);
}
export function CommunityTextPost({
event,
approvals,
showCommunity,
...props
}: Omit<CardProps, "children"> & CommunityPostPropTypes) {
const ref = useEventIntersectionRef(event);
const communityPointer = getEventCommunityPointer(event);
return (
<Card as={LinkBox} ref={ref} {...props}>
<PostSubject event={event} />
<CardBody p="2">
<CompactNoteContent event={event} maxLength={96} />
</CardBody>
<CardFooter display="flex" gap="2" alignItems="center" p="2" flexWrap="wrap">
<Text>
Posted {dayjs.unix(event.created_at).fromNow()} by <UserLink pubkey={event.pubkey} fontWeight="bold" />
</Text>
{showCommunity && communityPointer && (
<Text>
to{" "}
<Link as={RouterLink} to={`/c/${communityPointer.identifier}/${communityPointer.pubkey}`} fontWeight="bold">
{communityPointer.identifier}
</Link>
</Text>
)}
<Flex gap="2" alignItems="center" ml="auto">
{approvals.length > 0 && <Approvals approvals={approvals} />}
<CommunityPostMenu event={event} approvals={approvals} aria-label="More Options" size="xs" variant="ghost" />
</Flex>
</CardFooter>
</Card>
);
}
export function CommunityRepostPost({
event,
approvals,
showCommunity,
...props
}: Omit<CardProps, "children"> & CommunityPostPropTypes) {
const encodedRepost = parseHardcodedNoteContent(event);
const [_, eventId, relay] = event.tags.find(isETag) ?? [];
const readRelays = useReadRelays(relay ? [relay] : []);
const loadedRepost = useSingleEvent(eventId, readRelays);
const repost = encodedRepost || loadedRepost;
const ref = useEventIntersectionRef(repost);
const muteFilter = useUserMuteFilter();
if (repost && muteFilter(repost)) return;
const communityPointer = getEventCommunityPointer(event);
return (
<Card as={LinkBox} ref={ref} {...props}>
{repost && (
<>
<PostSubject event={repost} />
<CardBody p="2">
<CompactNoteContent event={repost} maxLength={96} />
</CardBody>
</>
)}
<CardFooter display="flex" gap="2" alignItems="center" p="2" flexWrap="wrap">
<Text>
Shared {dayjs.unix(event.created_at).fromNow()} by <UserLink pubkey={event.pubkey} fontWeight="bold" />
</Text>
{showCommunity && communityPointer && (
<Text>
to{" "}
<Link as={RouterLink} to={`/c/${communityPointer.identifier}/${communityPointer.pubkey}`} fontWeight="bold">
{communityPointer.identifier}
</Link>
</Text>
)}
<Flex gap="2" alignItems="center" ml="auto">
{approvals.length > 0 && <Approvals approvals={approvals} />}
<CommunityPostMenu event={event} approvals={approvals} aria-label="More Options" size="xs" variant="ghost" />
</Flex>
</CardFooter>
</Card>
);
}
export default function CommunityPost({ event, ...props }: Omit<CardProps, "children"> & CommunityPostPropTypes) {
switch (event.kind) {
case kinds.ShortTextNote:
return <CommunityTextPost event={event} {...props} />;
case kinds.Repost:
return <CommunityRepostPost event={event} {...props} />;
case kinds.GenericRepost:
return <CommunityRepostPost event={event} {...props} />;
}
return null;
}

View File

@ -1,140 +0,0 @@
import {
Box,
Button,
ButtonGroup,
Card,
CardBody,
CardProps,
Flex,
Heading,
Link,
SimpleGrid,
Text,
useDisclosure,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import {
getCommunityDescription,
getCommunityLinks,
getCommunityMods,
getCommunityRelays,
getCommunityRules,
} from "../../../helpers/nostr/communities";
import CommunityDescription from "../../communities/components/community-description";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";
import { NostrEvent } from "../../../types/nostr-event";
import CommunityJoinButton from "../../communities/components/community-join-button";
import CommunityMenu from "./community-menu";
import useCountCommunityMembers from "../../../hooks/use-count-community-members";
import { humanReadableSats } from "../../../helpers/lightning";
import CommunityMembersModal from "./community-members-modal";
import { RelayFavicon } from "../../../components/relay-favicon";
export default function HorizontalCommunityDetails({
community,
onEditClick,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent; onEditClick?: () => void }) {
const membersModal = useDisclosure();
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
const description = getCommunityDescription(community);
const rules = getCommunityRules(community);
const links = getCommunityLinks(community);
const more = useDisclosure();
const countMembers = useCountCommunityMembers(community);
return (
<>
<Card {...props}>
<CardBody>
<ButtonGroup float="right">
<CommunityJoinButton community={community} />
<CommunityMenu community={community} aria-label="More" onEditClick={onEditClick} />
</ButtonGroup>
{description && (
<>
<Heading size="sm" mb="1">
Description
</Heading>
<CommunityDescription community={community} mb="1" />
</>
)}
{more.isOpen ? (
<SimpleGrid spacing="4" columns={2}>
<Box>
<Heading size="sm" mb="1">
Mods
</Heading>
<Flex direction="column" gap="2">
{mods.map((pubkey) => (
<Flex key={pubkey} gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
</Flex>
))}
</Flex>
</Box>
<Box as="button" textAlign="start" cursor="pointer" onClick={membersModal.onOpen}>
<Heading size="sm" mb="1">
Members
</Heading>
<Text>{countMembers ? humanReadableSats(countMembers) : "unknown"}</Text>
</Box>
{rules && (
<Box>
<Heading size="sm" mb="1">
Rules
</Heading>
<Text whiteSpace="pre-wrap">{rules}</Text>
</Box>
)}
{communityRelays.length > 0 && (
<Box>
<Heading size="sm" mb="1">
Relays
</Heading>
<Flex direction="column" gap="2">
{communityRelays.map((url) => (
<Flex key={url} alignItems="center" gap="2">
<RelayFavicon relay={url} size="xs" />
<Link as={RouterLink} to={`/r/${encodeURIComponent(url)}`} fontWeight="bold" isTruncated>
{url}
</Link>
</Flex>
))}
</Flex>
</Box>
)}
{links.length > 0 && (
<Box>
<Heading size="sm" mb="1">
Links
</Heading>
<Box>
{links.map(([url, name]) => (
<Link key={url + name} href={url} isTruncated isExternal display="block">
{name || url}
</Link>
))}
</Box>
</Box>
)}
</SimpleGrid>
) : (
<Button variant="link" onClick={more.onOpen} w="full">
Show more
</Button>
)}
</CardBody>
</Card>
{membersModal.isOpen && (
<CommunityMembersModal isOpen={membersModal.isOpen} onClose={membersModal.onClose} community={community} />
)}
</>
);
}

View File

@ -1,115 +0,0 @@
import { Box, ButtonGroup, Card, CardProps, Flex, Heading, Link, Text, useDisclosure } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import {
getCommunityDescription,
getCommunityLinks,
getCommunityMods,
getCommunityRelays,
getCommunityRules,
} from "../../../helpers/nostr/communities";
import CommunityDescription from "../../communities/components/community-description";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";
import { NostrEvent } from "../../../types/nostr-event";
import CommunityJoinButton from "../../communities/components/community-join-button";
import CommunityMenu from "./community-menu";
import useCountCommunityMembers from "../../../hooks/use-count-community-members";
import CommunityMembersModal from "./community-members-modal";
import { humanReadableSats } from "../../../helpers/lightning";
import { RelayFavicon } from "../../../components/relay-favicon";
export default function VerticalCommunityDetails({
community,
onEditClick,
...props
}: Omit<CardProps, "children"> & { community: NostrEvent; onEditClick?: () => void }) {
const membersModal = useDisclosure();
const communityRelays = getCommunityRelays(community);
const mods = getCommunityMods(community);
const description = getCommunityDescription(community);
const rules = getCommunityRules(community);
const links = getCommunityLinks(community);
const countMembers = useCountCommunityMembers(community);
return (
<>
<Card p="4" gap="4" {...props}>
{description && (
<Box>
<Heading size="sm" mb="1">
About
</Heading>
<CommunityDescription community={community} maxLength={256} showExpand />
</Box>
)}
<ButtonGroup w="full">
<CommunityJoinButton community={community} flex={1} />
<CommunityMenu community={community} aria-label="More" onEditClick={onEditClick} />
</ButtonGroup>
<Box>
<Heading size="sm" mb="1">
Mods
</Heading>
<Flex direction="column" gap="2">
{mods.map((pubkey) => (
<Flex key={pubkey} gap="2">
<UserAvatarLink pubkey={pubkey} size="xs" />
<UserLink pubkey={pubkey} />
</Flex>
))}
</Flex>
</Box>
<Box as="button" textAlign="start" cursor="pointer" onClick={membersModal.onOpen}>
<Heading size="sm" mb="1">
Members
</Heading>
<Text>{countMembers ? humanReadableSats(countMembers) : "unknown"}</Text>
</Box>
{rules && (
<Box>
<Heading size="sm" mb="1">
Rules
</Heading>
<Text whiteSpace="pre-wrap">{rules}</Text>
</Box>
)}
{communityRelays.length > 0 && (
<Box>
<Heading size="sm" mb="1">
Relays
</Heading>
<Flex direction="column" gap="2">
{communityRelays.map((url) => (
<Flex key={url} alignItems="center" gap="2">
<RelayFavicon relay={url} size="xs" />
<Link as={RouterLink} to={`/r/${encodeURIComponent(url)}`} fontWeight="bold" isTruncated>
{url}
</Link>
</Flex>
))}
</Flex>
</Box>
)}
{links.length > 0 && (
<Box>
<Heading size="sm" mb="1">
Links
</Heading>
<Box>
{links.map(([url, name]) => (
<Link key={url + name} href={url} isTruncated isExternal display="block">
{name || url}
</Link>
))}
</Box>
</Box>
)}
</Card>
{membersModal.isOpen && (
<CommunityMembersModal isOpen={membersModal.isOpen} onClose={membersModal.onClose} community={community} />
)}
</>
);
}

View File

@ -1,47 +0,0 @@
import { useCallback } from "react";
import { Navigate, useParams } from "react-router-dom";
import { Heading, SimpleGrid } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { useReadRelays } from "../../hooks/use-client-relays";
import { validateCommunity } from "../../helpers/nostr/communities";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import VerticalPageLayout from "../../components/vertical-page-layout";
import CommunityCard from "../communities/components/community-card";
import { getEventUID } from "../../helpers/nostr/event";
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 === kinds.CommunityDefinition) {
return <Navigate to={`/c/${decoded.data.identifier}/${decoded.data.pubkey}`} replace />;
}
const readRelays = useReadRelays();
const eventFilter = useCallback((event: NostrEvent) => {
return validateCommunity(event);
}, []);
const { loader, timeline: communities } = useTimelineLoader(
`${community}-find-communities`,
readRelays,
community ? { kinds: [kinds.CommunityDefinition], "#d": [community] } : undefined,
);
const callback = useTimelineCurserIntersectionCallback(loader);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<Heading>Select Community</Heading>
<SimpleGrid spacing="2" columns={{ base: 1, lg: 2 }}>
{communities?.map((event) => <CommunityCard key={getEventUID(event)} community={event} />)}
</SimpleGrid>
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}

View File

@ -1,30 +0,0 @@
import { useParams } from "react-router-dom";
import { Spinner } from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import CommunityHomePage from "./community-home";
import { getPubkeyFromDecodeResult, 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 === kinds.CommunityDefinition) return decoded.data;
} else if (community && pubkey) {
const hexPubkey = isHexKey(pubkey) ? pubkey : getPubkeyFromDecodeResult(safeDecode(pubkey));
if (!hexPubkey) return;
return { kind: kinds.CommunityDefinition, pubkey: hexPubkey, identifier: community };
}
}
export default function CommunityView() {
const pointer = useCommunityPointer();
const community = useReplaceableEvent(pointer, undefined, { alwaysRequest: true });
if (!community) return <Spinner />;
return <CommunityHomePage community={community} />;
}

View File

@ -1,38 +0,0 @@
import { useOutletContext } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { kinds } from "nostr-tools";
import { buildApprovalMap, getCommunityMods } from "../../../helpers/nostr/communities";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import TimelineActionAndStatus from "../../../components/timeline/timeline-action-and-status";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
import ApprovedEvent from "../components/community-approved-post";
import { RouterContext } from "../community-home";
export default function CommunityNewestView() {
const { community, timeline } = useOutletContext<RouterContext>();
const muteFilter = useUserMuteFilter();
const mods = getCommunityMods(community);
const events = useObservable(timeline.timeline) ?? [];
const approvalMap = buildApprovalMap(events, mods);
const approved = events
.filter((e) => e.kind !== kinds.CommunityPostApproval && approvalMap.has(e.id))
.map((event) => ({ event, approvals: approvalMap.get(event.id) }))
.filter((e) => !muteFilter(e.event));
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<>
<IntersectionObserverProvider callback={callback}>
{approved.map(({ event, approvals }) => (
<ApprovedEvent key={event.id} event={event} approvals={approvals ?? []} />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} />
</>
);
}

View File

@ -1,101 +0,0 @@
import { useCallback, useState } from "react";
import { Button, Flex } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { useObservable } from "applesauce-react/hooks";
import { kinds } from "nostr-tools";
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { getEventCoordinate, getEventUID } from "../../../helpers/nostr/event";
import { buildApprovalMap, getCommunityMods, getCommunityRelays } from "../../../helpers/nostr/communities";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../../components/timeline/timeline-action-and-status";
import { CheckIcon } from "../../../components/icons";
import useCurrentAccount from "../../../hooks/use-current-account";
import CommunityPost from "../components/community-post";
import { RouterContext } from "../community-home";
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
type PendingProps = {
event: NostrEvent;
approvals: NostrEvent[];
community: NostrEvent;
};
function ModPendingPost({ event, community, approvals }: PendingProps) {
const publish = usePublishEvent();
const ref = useEventIntersectionRef(event);
const communityRelays = getCommunityRelays(community);
const [loading, setLoading] = useState(false);
const approve = useCallback(async () => {
setLoading(true);
const relay = communityRelays[0];
const draft: DraftNostrEvent = {
kind: kinds.CommunityPostApproval,
content: JSON.stringify(event),
created_at: dayjs().unix(),
tags: [
relay ? ["a", getEventCoordinate(community), relay] : ["a", getEventCoordinate(community)],
["e", event.id],
["p", event.pubkey],
["k", String(event.kind)],
],
};
await publish("Approve", draft);
setLoading(false);
}, [event, publish, setLoading, community]);
return (
<Flex direction="column" gap="2" ref={ref}>
<CommunityPost event={event} approvals={approvals} />
<Flex gap="2">
<Button
colorScheme="primary"
leftIcon={<CheckIcon />}
size="sm"
ml="auto"
onClick={approve}
isLoading={loading}
>
Approve
</Button>
</Flex>
</Flex>
);
}
export default function CommunityPendingView() {
const account = useCurrentAccount();
const muteFilter = useUserMuteFilter();
const { community, timeline } = useOutletContext<RouterContext>();
const events = useObservable(timeline.timeline) ?? [];
const mods = getCommunityMods(community);
const approvals = buildApprovalMap(events, mods);
const pending = events.filter(
(e) => e.kind !== kinds.CommunityPostApproval && !approvals.has(e.id) && !muteFilter(e),
);
const callback = useTimelineCurserIntersectionCallback(timeline);
const isMod = !!account && mods.includes(account?.pubkey);
const PostComponent = isMod ? ModPendingPost : CommunityPost;
return (
<>
<IntersectionObserverProvider callback={callback}>
{pending.map((event) => (
<PostComponent key={getEventUID(event)} event={event} community={community} approvals={[]} />
))}
</IntersectionObserverProvider>
<TimelineActionAndStatus timeline={timeline} />
</>
);
}

View File

@ -21,7 +21,7 @@ import DirectMessageBlock from "./components/direct-message-block";
import useParamsProfilePointer from "../../hooks/use-params-pubkey-pointer";
import useUserMailboxes from "../../hooks/use-user-mailboxes";
import RelaySet from "../../classes/relay-set";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import { truncateId } from "../../helpers/string";
import useRouterMarker from "../../hooks/use-router-marker";
import { BackIconButton } from "../../components/router/back-button";

View File

@ -4,7 +4,7 @@ import { NostrEvent } from "nostr-tools";
import { UnlockIcon } from "../../../components/icons";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import { useKind4Decrypt } from "../../../hooks/use-kind4-decryption";
export default function DecryptPlaceholder({

View File

@ -5,7 +5,7 @@ import useTimelineLoader from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { FILE_KIND, IMAGE_TYPES, VIDEO_TYPES, getFileUrl, parseImageFile } from "../../helpers/nostr/files";
import { ErrorBoundary } from "../../components/error-boundary";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import { TrustProvider, useTrustContext } from "../../providers/local/trust-provider";
import BlurredImage from "../../components/blured-image";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";

View File

@ -53,7 +53,6 @@ function RenderRedirect({ event, link }: { event?: NostrEvent; link: string }) {
if (k === kinds.Followsets) return <Navigate to={`/lists/${link}`} replace />;
if (k === kinds.Bookmarksets) return <Navigate to={`/lists/${link}`} replace />;
if (k === kinds.BadgeDefinition) return <Navigate to={`/badges/${link}`} replace />;
if (k === kinds.CommunityDefinition) return <Navigate to={`/c/${link}`} replace />;
if (k === FLARE_VIDEO_KIND) return <Navigate to={`/videos/${link}`} replace />;
if (k === kinds.ChannelCreation) return <Navigate to={`/channels/${link}`} replace />;
if (k === kinds.ShortTextNote) return <Navigate to={`/n/${link}`} replace />;

View File

@ -29,7 +29,7 @@ import { NostrEvent } from "../../../types/nostr-event";
import useReplaceableEvent from "../../../hooks/use-replaceable-event";
import ListFavoriteButton from "./list-favorite-button";
import ListMenu from "./list-menu";
import { CommunityIcon, NotesIcon } from "../../../components/icons";
import { NotesIcon } from "../../../components/icons";
import User01 from "../../../components/icons/user-01";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import NoteZapButton from "../../../components/note/note-zap-button";
@ -40,11 +40,10 @@ import { createCoordinate } from "../../../classes/batch-kind-pubkey-loader";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import { getSharableEventAddress } from "../../../services/relay-hints";
export function ListCardContent({ list, ...props }: Omit<CardProps, "children"> & { list: NostrEvent }) {
export function ListCardContent({ list }: { list: NostrEvent }) {
const people = getPubkeysFromList(list);
const notes = getEventPointersFromList(list);
const coordinates = getAddressPointersFromList(list);
const communities = coordinates.filter((cord) => cord.kind === kinds.CommunityDefinition);
const articles = coordinates.filter((cord) => cord.kind === kinds.LongFormArticle);
const references = getReferencesFromList(list);
@ -70,11 +69,6 @@ export function ListCardContent({ list, ...props }: Omit<CardProps, "children">
<File02 /> {articles.length}
</Text>
)}
{communities.length > 0 && (
<Text>
<CommunityIcon boxSize={5} /> {communities.length}
</Text>
)}
</SimpleGrid>
);
}

View File

@ -11,7 +11,7 @@ import MediaPostSlides from "../../components/media-post/media-slides";
import MediaPostContents from "../../components/media-post/media-post-content";
import { TrustProvider } from "../../providers/local/trust-provider";
import DebugEventButton from "../../components/debug-modal/debug-event-button";
import RepostButton from "../../components/note/timeline-note/components/repost-button";
import ShareButton from "../../components/note/timeline-note/components/share-button";
import QuoteEventButton from "../../components/note/quote-event-button";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import EventZapIconButton from "../../components/zap/event-zap-icon-button";
@ -30,7 +30,7 @@ function Header({ post }: { post: NostrEvent }) {
</Flex>
<ButtonGroup ml="auto">
<RepostButton event={post} />
<ShareButton event={post} />
<QuoteEventButton event={post} />
<DebugEventButton event={post} />
</ButtonGroup>

View File

@ -29,7 +29,7 @@ import { Emoji } from "applesauce-core/helpers";
import { useFinalizeDraft, usePublishEvent } from "../../../providers/global/publish-provider";
import useCurrentAccount from "../../../hooks/use-current-account";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import localSettings from "../../../services/local-settings";
import useLocalStorageDisclosure from "../../../hooks/use-localstorage-disclosure";
import PublishAction from "../../../classes/nostr-publish-action";
@ -46,13 +46,11 @@ import { ChevronDownIcon, ChevronUpIcon } from "../../../components/icons";
import ZapSplitCreator, { Split } from "./zap-split-creator";
import MinePOW from "../../../components/pow/mine-pow";
import { PublishDetails } from "../../task-manager/publish-log/publish-details";
import CommunitySelect from "../../../components/post-modal/community-select";
type FormValues = {
content: string;
nsfw: boolean;
nsfwReason: string;
community: string;
split: Split[];
difficulty: number;
};
@ -60,13 +58,11 @@ type FormValues = {
export type ShortTextNoteFormProps = {
cacheFormKey?: string | null;
initContent?: string;
initCommunity?: string;
};
export default function ShortTextNoteForm({
cacheFormKey = "new-note",
initContent = "",
initCommunity = "",
}: Omit<FlexProps, "children"> & ShortTextNoteFormProps) {
const publish = usePublishEvent();
const finalizeDraft = useFinalizeDraft();
@ -86,7 +82,6 @@ export default function ShortTextNoteForm({
content: initContent,
nsfw: false,
nsfwReason: "",
community: initCommunity,
split: [] as Split[],
difficulty: noteDifficulty || 0,
},
@ -112,9 +107,6 @@ export default function ShortTextNoteForm({
splits: values.split,
});
// TODO: remove when NIP-72 communities are removed
if (values.community) draft.tags.push(["a", values.community]);
const unsigned = await finalizeDraft(draft);
setDraft(unsigned);
@ -232,10 +224,6 @@ export default function ShortTextNoteForm({
{showAdvanced && (
<Flex direction={{ base: "column", lg: "row" }} gap="4">
<Flex direction="column" gap="2" flex={1}>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && (

View File

@ -3,7 +3,6 @@ import {
BadgeIcon,
BookmarkIcon,
ChannelsIcon,
CommunityIcon,
DirectMessagesIcon,
EmojiPacksIcon,
GoalIcon,
@ -40,13 +39,6 @@ export const internalApps: App[] = [
id: "media",
to: "/media",
},
{
title: "Communities",
description: "Create and manage communities",
icon: CommunityIcon,
id: "communities",
to: "/communities",
},
{ title: "Wiki", description: "Browse wiki pages", icon: WikiIcon, id: "wiki", to: "/wiki" },
{
title: "Channels",
@ -88,13 +80,6 @@ export const internalTools: App[] = [
id: "unknown",
to: "/tools/unknown",
},
{
title: "Satellite CDN",
description: "Scalable media hosting for the nostr ecosystem",
image: "https://satellite.earth/image.png",
id: "satellite-cdn",
to: "/tools/satellite-cdn",
},
{ title: "Map", description: "Explore events with geohashes", icon: MapIcon, id: "map", to: "/map" },
{
title: "Stream Moderation",
@ -189,6 +174,14 @@ export const externalTools: App[] = [
image: "https://nostr-delete.vercel.app/favicon.png",
isExternal: true,
},
{
title: "Satellite CDN",
description: "Scalable media hosting for the nostr ecosystem",
image: "https://satellite.earth/image.png",
id: "satellite-cdn",
to: "https://satellite.earth/cdn",
isExternal: true,
},
{
id: "w3.do",
title: "URL Shortener",

View File

@ -3,27 +3,24 @@ import { Flex, FlexProps, Tag, Tooltip } from "@chakra-ui/react";
// copied from github
export const NIP_NAMES: Record<string, string> = {
"01": "Basic protocol",
"02": "Contact List and Petnames",
"02": "Follow List",
"03": "OpenTimestamps Attestations for Events",
"04": "Encrypted Direct Message",
"05": "Mapping Nostr keys to DNS-based internet identifiers",
"06": "Basic key derivation from mnemonic seed phrase",
"07": "window.nostr capability for web browsers",
"08": "Handling Mentions",
"09": "Event Deletion",
"10": "Conventions for clients' use of e and p tags in text events",
"09": "Event Deletion Request",
"10": "Conventions for clients' use of `e` and `p` tags in text events",
"11": "Relay Information Document",
"12": "Generic Tag Queries",
"13": "Proof of Work",
"14": "Subject tag in Text events",
"15": "Nostr Marketplace",
"16": "Event Treatment",
"14": "Subject tag in text events",
"15": "Nostr Marketplace (for resilient marketplaces)",
"17": "Private Direct Messages",
"18": "Reposts",
"19": "bech32-encoded entities",
"20": "Command Results",
"21": "nostr: URI scheme",
"22": "Generic Comments",
"22": "Comment",
"23": "Long-form Content",
"24": "Extra metadata fields and tags",
"25": "Reactions",
@ -34,10 +31,10 @@ export const NIP_NAMES: Record<string, string> = {
"30": "Custom Emoji",
"31": "Dealing with Unknown Events",
"32": "Labeling",
"33": "Parameterized Replaceable Events",
"34": "git stuff",
"35": "Torrents",
"36": "Sensitive Content / Content Warning",
"36": "Sensitive Content",
"37": "Draft Events",
"38": "User Statuses",
"39": "External Identities in Profiles",
"40": "Expiration Timestamp",
@ -58,8 +55,12 @@ export const NIP_NAMES: Record<string, string> = {
"57": "Lightning Zaps",
"58": "Badges",
"59": "Gift Wrap",
"64": "Chess",
"60": "Cashu Wallet",
"61": "Nutzaps",
"64": "Chess (PGN)",
"65": "Relay List Metadata",
"68": "Picture-first feeds",
"69": "Peer-to-peer Order events",
"70": "Protected Events",
"71": "Video Events",
"72": "Moderated Communities",
@ -67,6 +68,7 @@ export const NIP_NAMES: Record<string, string> = {
"75": "Zap Goals",
"78": "Application-specific data",
"84": "Highlights",
"86": "Relay Management API",
"89": "Recommended Application Handlers",
"90": "Data Vending Machines",
"92": "Media Attachments",
@ -74,6 +76,8 @@ export const NIP_NAMES: Record<string, string> = {
"96": "HTTP File Storage Integration",
"98": "HTTP Auth",
"99": "Classified Listings",
"7D": "Threads",
C7: "Chats",
};
function NipTag({ nip, name }: { nip: number; name?: boolean }) {

View File

@ -32,7 +32,7 @@ import BackButton from "../../../components/router/back-button";
import useUsersMediaServers from "../../../hooks/use-user-media-servers";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";
import { cloneEvent } from "../../../helpers/nostr/event";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import useAsyncErrorHandler from "../../../hooks/use-async-error-handler";
import { isServerTag } from "../../../helpers/nostr/blossom";
import { USER_BLOSSOM_SERVER_LIST_KIND, areServersEqual } from "blossom-client-sdk";

View File

@ -1,5 +1,5 @@
import { useToast } from "@chakra-ui/react";
import useAppSettings from "../../hooks/use-app-settings";
import useAppSettings from "../../hooks/use-user-app-settings";
import { useForm } from "react-hook-form";
export default function useSettingsForm() {

View File

@ -13,7 +13,7 @@ export type StreamShareButtonProps = Omit<ButtonProps, "children" | "onClick"> &
export default function StreamShareButton({
stream,
"aria-label": ariaLabel,
title = "Quote repost",
title = "Quote share",
...props
}: StreamShareButtonProps) {
const { openModal } = useContext(PostModalContext);

View File

@ -15,10 +15,10 @@ import Expand01 from "../../../components/icons/expand-01";
import Minus from "../../../components/icons/minus";
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props";
import POWIcon from "../../../components/pow/pow-icon";
import RepostButton from "../../../components/note/timeline-note/components/repost-button";
import ShareButton from "../../../components/note/timeline-note/components/share-button";
import QuoteEventButton from "../../../components/note/quote-event-button";
import NoteZapButton from "../../../components/note/note-zap-button";
import NoteProxyLink from "../../../components/note/timeline-note/components/note-proxy-link";
@ -114,7 +114,7 @@ function ThreadPost({ post, initShowReplies, focusId, level = -1 }: ThreadItemPr
<Flex gap="2" alignItems="center">
<ButtonGroup variant="ghost" size="sm">
<IconButton aria-label="Reply" title="Reply" onClick={replyForm.onToggle} icon={<ReplyIcon />} />
<RepostButton event={post.event} />
<ShareButton event={post.event} />
<QuoteEventButton event={post.event} />
<NoteZapButton event={post.event} />
</ButtonGroup>

View File

@ -20,7 +20,7 @@ import useThreadTimelineLoader from "../../../hooks/use-thread-timeline-loader";
import { countReplies, repliesByDate } from "../../../helpers/thread";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import useClientSideMuteFilter from "../../../hooks/use-client-side-mute-filter";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";

View File

@ -42,7 +42,6 @@ import UserZapButton from "../components/user-zap-button";
import { UserProfileMenu } from "../components/user-profile-menu";
import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id";
import UserProfileBadges from "./user-profile-badges";
import UserJoinedCommunities from "./user-joined-communities";
import UserPinnedEvents from "./user-pinned-events";
import UserStatsAccordion from "./user-stats-accordion";
import UserJoinedChanneled from "./user-joined-channels";
@ -51,6 +50,7 @@ import UserName from "../../../components/user/user-name";
import { useUserDNSIdentity } from "../../../hooks/use-user-dns-identity";
import UserAboutContent from "../../../components/user/user-about";
import UserRecentEvents from "./user-recent-events";
import useAppSettings, { useUserAppSettings } from "../../../hooks/use-user-app-settings";
function DNSIdentityWarning({ pubkey }: { pubkey: string }) {
const metadata = useUserProfile(pubkey);
@ -98,6 +98,7 @@ export default function UserAboutTab() {
const npub = nip19.npubEncode(pubkey);
const nprofile = useSharableProfileId(pubkey);
const pubkeyColor = "#" + pubkey.slice(0, 6);
const settings = useUserAppSettings(pubkey);
const parsedNip05 = metadata?.nip05 ? parseAddress(metadata.nip05) : undefined;
const nip05URL = parsedNip05
@ -215,6 +216,13 @@ export default function UserAboutTab() {
<QrIconButton pubkey={pubkey} title="Show QrCode" aria-label="Show QrCode" size="xs" />
</Flex>
)}
{settings?.primaryColor && (
<Flex gap="2">
<Box w="5" h="5" backgroundColor={settings.primaryColor} rounded="full" />
<Text>noStrudel theme color</Text>
</Flex>
)}
</Flex>
<UserProfileBadges pubkey={pubkey} px="2" />
@ -254,7 +262,6 @@ export default function UserAboutTab() {
Nostree page
</Button>
</Flex>
<UserJoinedCommunities pubkey={pubkey} />
<UserJoinedChanneled pubkey={pubkey} />
<Modal isOpen={colorModal.isOpen} onClose={colorModal.onClose} size="2xl">

View File

@ -1,36 +0,0 @@
import { Button, Flex, Heading, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import { useAdditionalRelayContext } from "../../../providers/local/additional-relay-context";
import useUserCommunitiesList from "../../../hooks/use-user-communities-list";
import { PointerCommunityCard } from "../../communities/components/community-card";
import { ErrorBoundary } from "../../../components/error-boundary";
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
export default function UserJoinedCommunities({ pubkey }: { pubkey: string }) {
const contextRelays = useAdditionalRelayContext();
const { pointers: communities } = useUserCommunitiesList(pubkey, contextRelays, { alwaysRequest: true });
const columns = useBreakpointValue({ base: 1, lg: 2, xl: 3 }) ?? 1;
const showAllCommunities = useDisclosure();
if (communities.length === 0) return null;
return (
<Flex direction="column" px="2">
<Heading size="md" my="2">
Joined Communities ({communities.length})
</Heading>
<SimpleGrid spacing="4" columns={columns}>
{(showAllCommunities.isOpen ? communities : communities.slice(0, columns * 2)).map((pointer) => (
<ErrorBoundary key={pointer.identifier + pointer.pubkey}>
<PointerCommunityCard pointer={pointer} />
</ErrorBoundary>
))}
</SimpleGrid>
{!showAllCommunities.isOpen && communities.length > columns * 2 && (
<Button variant="link" pt="4" onClick={showAllCommunities.onOpen}>
Show All
</Button>
)}
</Flex>
);
}

View File

@ -6,7 +6,6 @@ import {
ArticleIcon,
BookmarkIcon,
ChannelsIcon,
CommunityIcon,
DirectMessagesIcon,
EmojiPacksIcon,
ListsIcon,
@ -110,8 +109,6 @@ const KnownKinds: KnownKind[] = [
{ kind: kinds.Handlerinformation, name: "Application" },
{ kind: kinds.Handlerrecommendation, name: "App recommendation" },
{ kind: kinds.CommunityDefinition, icon: CommunityIcon, name: "Communities" },
{ kind: kinds.BadgeAward, name: "Badge Award" },
{ kind: kinds.LiveChatMessage, icon: MessageSquare02, name: "Stream Chat" },

View File

@ -8,7 +8,7 @@ import EasyMDE from "easymde";
import "easymde/dist/easymde.min.css";
import useUsersMediaServers from "../../../hooks/use-user-media-servers";
import useAppSettings from "../../../hooks/use-app-settings";
import useAppSettings from "../../../hooks/use-user-app-settings";
import useCurrentAccount from "../../../hooks/use-current-account";
import { CharkaMarkdown } from "../../../components/markdown/markdown";