diff --git a/src/services/relay-info.ts b/src/services/relay-info.ts
index 71a29382f..ffe9f11c0 100644
--- a/src/services/relay-info.ts
+++ b/src/services/relay-info.ts
@@ -33,7 +33,7 @@ async function getInfo(relay: string) {
const cached = await db.get("relayInfo", relay);
if (cached) {
memoryCache.set(relay, cached);
- return cached;
+ return cached as RelayInformationDocument;
}
return fetchInfo(relay);
diff --git a/src/views/relays/add-custom-modal.tsx b/src/views/relays/add-custom-modal.tsx
new file mode 100644
index 000000000..e867f8466
--- /dev/null
+++ b/src/views/relays/add-custom-modal.tsx
@@ -0,0 +1,105 @@
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Code,
+ Flex,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Input,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ ModalProps,
+ Text,
+ useDisclosure,
+} from "@chakra-ui/react";
+import { useState } from "react";
+import { useRelayInfo } from "../../hooks/use-relay-info";
+import { UserAvatar } from "../../components/user-avatar";
+import { UserLink } from "../../components/user-link";
+import { safeRelayUrl } from "../../helpers/url";
+import { useDebounce } from "react-use";
+import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
+import { CodeIcon } from "../../components/icons";
+import { Metadata } from "./relay-card";
+
+function RelayDetails({ url, debug }: { url: string; debug?: boolean }) {
+ const { info } = useRelayInfo(url);
+
+ if (!info) return null;
+
+ return (
+
+ {info.name}
+ {url}
+ {info.pubkey && (
+
+ Owner:
+
+
+
+
+ )}
+ {info.supported_nips?.join(", ")}
+ {debug && (
+
+ {JSON.stringify(info, null, 2)}
+
+ )}
+
+ );
+}
+
+export default function AddCustomRelayModal({
+ onSubmit,
+ ...props
+}: { onSubmit: (relay: string) => void } & Omit) {
+ const [url, setUrl] = useState("");
+ const [safeUrl, setSafeUrl] = useState();
+ const showDebug = useDisclosure();
+
+ useDebounce(() => setSafeUrl(safeRelayUrl(url) ?? undefined), 1000, [url]);
+
+ return (
+
+
+
+ Custom Relay
+
+
+
+ Relay URL
+ setUrl(e.target.value)}
+ />
+
+
+ {safeUrl && }
+
+
+
+
+ {safeUrl && (
+ } aria-label="Show JSON" onClick={showDebug.onToggle} variant="ghost" />
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx
index befc23fe7..b0fc24708 100644
--- a/src/views/relays/index.tsx
+++ b/src/views/relays/index.tsx
@@ -1,170 +1,21 @@
-import {
- Box,
- Button,
- ButtonGroup,
- Card,
- CardBody,
- CardHeader,
- Divider,
- Flex,
- Heading,
- Input,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalHeader,
- ModalOverlay,
- ModalProps,
- SimpleGrid,
- Spacer,
- Switch,
- Text,
- useDisclosure,
-} from "@chakra-ui/react";
-import { useAsync } from "react-use";
-import { Link as RouterLink } from "react-router-dom";
-import { useRelayInfo } from "../../hooks/use-relay-info";
-import { RelayFavicon } from "../../components/relay-favicon";
import { useDeferredValue, useMemo, useState } from "react";
-import { ExternalLinkIcon } from "../../components/icons";
-import { UserLink } from "../../components/user-link";
-import { UserAvatar } from "../../components/user-avatar";
-import { useClientRelays, useReadRelayUrls } from "../../hooks/use-client-relays";
+import { Button, Divider, Flex, Heading, Input, SimpleGrid, Spacer, Switch, useDisclosure } from "@chakra-ui/react";
+import { useAsync } from "react-use";
+
+import { useClientRelays } from "../../hooks/use-client-relays";
+import relayPoolService from "../../services/relay-pool";
+import { safeRelayUrl } from "../../helpers/url";
+import AddCustomRelayModal from "./add-custom-modal";
+import RelayCard from "./relay-card";
import clientRelaysService from "../../services/client-relays";
import { RelayMode } from "../../classes/relay";
-import useTimelineLoader from "../../hooks/use-timeline-loader";
-import useSubject from "../../hooks/use-subject";
-import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
-import { UserAvatarLink } from "../../components/user-avatar-link";
-import { NostrEvent } from "../../types/nostr-event";
-import dayjs from "dayjs";
-import { safeJson } from "../../helpers/parse";
-import StarRating from "../../components/star-rating";
-import relayPoolService from "../../services/relay-pool";
-import { useCurrentAccount } from "../../hooks/use-current-account";
-import { safeRelayUrl } from "../../helpers/url";
-
-function RelayReviewNote({ event }: { event: NostrEvent }) {
- const ratingJson = event.tags.find((t) => t[0] === "l" && t[3])?.[3];
- const rating = ratingJson ? (safeJson(ratingJson, undefined) as { quality: number } | undefined) : undefined;
-
- return (
-
-
-
-
-
-
- {dayjs.unix(event.created_at).fromNow()}
-
-
- {rating && }
- {event.content}
-
-
- );
-}
-function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit) {
- const readRelays = useReadRelayUrls();
- const timeline = useTimelineLoader(`${relay}-reviews`, readRelays, {
- kinds: [1985],
- "#r": [relay],
- "#l": ["review/relay"],
- });
-
- const events = useSubject(timeline.timeline);
-
- return (
-
-
-
-
- {relay} reviews
-
-
-
-
- {events.map((event) => (
-
- ))}
-
-
-
-
- );
-}
-
-function RelayCard({ url }: { url: string }) {
- const account = useCurrentAccount();
- const { info } = useRelayInfo(url);
- const clientRelays = useClientRelays();
- const reviewsModal = useDisclosure();
-
- const joined = clientRelays.some((r) => r.url === url);
-
- return (
- <>
-
-
-
-
- {url}
-
-
-
- {info?.pubkey && (
-
- Owner:
-
-
-
-
- )}
-
- {joined ? (
-
- ) : (
-
- )}
-
-
- }
- >
- More info
-
-
-
-
- {reviewsModal.isOpen && }
- >
- );
-}
export default function RelaysView() {
const [search, setSearch] = useState("");
const deboundedSearch = useDeferredValue(search);
const isSearching = deboundedSearch.length > 2;
const showAll = useDisclosure();
+ const addRelayModal = useDisclosure();
const clientRelays = useClientRelays().map((r) => r.url);
const discoveredRelays = relayPoolService
@@ -191,6 +42,10 @@ export default function RelaysView() {
Show All
+
+
{filteredRelays.map((url) => (
@@ -209,6 +64,18 @@ export default function RelaysView() {
>
)}
+
+ {addRelayModal.isOpen && (
+ {
+ clientRelaysService.addRelay(url, RelayMode.ALL);
+ addRelayModal.onClose();
+ }}
+ />
+ )}
);
}
diff --git a/src/views/relays/relay-card.tsx b/src/views/relays/relay-card.tsx
new file mode 100644
index 000000000..60b1a152f
--- /dev/null
+++ b/src/views/relays/relay-card.tsx
@@ -0,0 +1,184 @@
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Card,
+ CardBody,
+ CardFooter,
+ CardHeader,
+ Code,
+ Flex,
+ Heading,
+ IconButton,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+ ModalProps,
+ Spacer,
+ Text,
+ useDisclosure,
+} from "@chakra-ui/react";
+import { Link as RouterLink } from "react-router-dom";
+import { useRelayInfo } from "../../hooks/use-relay-info";
+import { RelayFavicon } from "../../components/relay-favicon";
+import { CodeIcon, ExternalLinkIcon } from "../../components/icons";
+import { UserLink } from "../../components/user-link";
+import { UserAvatar } from "../../components/user-avatar";
+import { useClientRelays, useReadRelayUrls } from "../../hooks/use-client-relays";
+import clientRelaysService from "../../services/client-relays";
+import { RelayMode } from "../../classes/relay";
+import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
+import { useCurrentAccount } from "../../hooks/use-current-account";
+import useSubject from "../../hooks/use-subject";
+import useTimelineLoader from "../../hooks/use-timeline-loader";
+import RelayReviewNote from "./relay-review-note";
+import styled from "@emotion/styled";
+import { PropsWithChildren } from "react";
+import RawJson from "../../components/debug-modals/raw-json";
+
+function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit) {
+ const readRelays = useReadRelayUrls();
+ const timeline = useTimelineLoader(`${relay}-reviews`, readRelays, {
+ kinds: [1985],
+ "#r": [relay],
+ "#l": ["review/relay"],
+ });
+
+ const events = useSubject(timeline.timeline);
+
+ return (
+
+
+
+
+ {relay} reviews
+
+
+
+
+ {events.map((event) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+const B = styled.span`
+ font-weight: bold;
+`;
+export const Metadata = ({ name, children }: { name: string } & PropsWithChildren) =>
+ children ? (
+
+ {name}:
+ {children}
+
+ ) : null;
+
+export function RelayMetadata({ url }: { url: string }) {
+ const { info } = useRelayInfo(url);
+
+ return (
+
+ {info?.name}
+ {info?.pubkey && (
+
+ Owner:
+
+
+
+
+ )}
+
+ );
+}
+
+export default function RelayCard({ url }: { url: string }) {
+ const account = useCurrentAccount();
+ const { info } = useRelayInfo(url);
+ const clientRelays = useClientRelays();
+ const reviewsModal = useDisclosure();
+ const debugModal = useDisclosure();
+
+ const joined = clientRelays.some((r) => r.url === url);
+
+ return (
+ <>
+
+
+
+
+ {url}
+
+
+
+
+
+
+ {joined ? (
+
+ ) : (
+
+ )}
+
+
+
+ }
+ aria-label="Show JSON"
+ onClick={debugModal.onToggle}
+ variant="ghost"
+ size="sm"
+ ml="auto"
+ />
+ }
+ size="sm"
+ >
+ More
+
+
+
+ {reviewsModal.isOpen && }
+ {debugModal.isOpen && (
+
+
+
+ Relay Info
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/views/relays/relay-review-note.tsx b/src/views/relays/relay-review-note.tsx
new file mode 100644
index 000000000..31c729945
--- /dev/null
+++ b/src/views/relays/relay-review-note.tsx
@@ -0,0 +1,30 @@
+import dayjs from "dayjs";
+import { Box, Card, CardBody, CardHeader, Spacer, Text } from "@chakra-ui/react";
+
+import { UserAvatarLink } from "../../components/user-avatar-link";
+import { UserLink } from "../../components/user-link";
+import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
+import StarRating from "../../components/star-rating";
+import { safeJson } from "../../helpers/parse";
+import { NostrEvent } from "../../types/nostr-event";
+
+export default function RelayReviewNote({ event }: { event: NostrEvent }) {
+ const ratingJson = event.tags.find((t) => t[0] === "l" && t[3])?.[3];
+ const rating = ratingJson ? (safeJson(ratingJson, undefined) as { quality: number } | undefined) : undefined;
+
+ return (
+
+
+
+
+
+
+ {dayjs.unix(event.created_at).fromNow()}
+
+
+ {rating && }
+ {event.content}
+
+
+ );
+}