rebuild relay view

This commit is contained in:
hzrd149
2023-08-03 22:01:20 -05:00
parent 6d1ce1df91
commit 33da3e2fa9
17 changed files with 337 additions and 195 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild relay view and show relay reviews

View File

@@ -20,7 +20,6 @@ import NoteView from "./views/note";
import LoginStartView from "./views/login/start";
import LoginNpubView from "./views/login/npub";
import NotificationsView from "./views/notifications";
import RelaysView from "./views/relays";
import LoginNip05View from "./views/login/nip05";
import LoginNsecView from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps";
@@ -35,6 +34,7 @@ import UserLikesTab from "./views/user/likes";
import useSetColorMode from "./hooks/use-set-color-mode";
import UserStreamsTab from "./views/user/streams";
import { PageProviders } from "./providers";
import RelaysView from "./views/relays";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));

View File

@@ -43,7 +43,7 @@ export const ConnectedRelays = () => {
return (
<>
<Button variant="link" onClick={onOpen} leftIcon={<RelayIcon />}>
{connected.length}/{relays.length} of relays connected
{connected.length} relays connected
</Button>
<Modal isOpen={isOpen} onClose={onClose} size="5xl">
<ModalOverlay />

View File

@@ -283,3 +283,21 @@ export const MapIcon = createIcon({
d: "M4 6.14286V18.9669L9.06476 16.7963L15.0648 19.7963L20 17.6812V4.85714L21.303 4.2987C21.5569 4.18992 21.8508 4.30749 21.9596 4.56131C21.9862 4.62355 22 4.69056 22 4.75827V19L15 22L9 19L2.69696 21.7013C2.44314 21.8101 2.14921 21.6925 2.04043 21.4387C2.01375 21.3765 2 21.3094 2 21.2417V7L4 6.14286ZM16.2426 11.2426L12 15.4853L7.75736 11.2426C5.41421 8.89949 5.41421 5.10051 7.75736 2.75736C10.1005 0.414214 13.8995 0.414214 16.2426 2.75736C18.5858 5.10051 18.5858 8.89949 16.2426 11.2426ZM12 12.6569L14.8284 9.82843C16.3905 8.26633 16.3905 5.73367 14.8284 4.17157C13.2663 2.60948 10.7337 2.60948 9.17157 4.17157C7.60948 5.73367 7.60948 8.26633 9.17157 9.82843L12 12.6569Z",
defaultProps,
});
export const StarEmptyIcon = createIcon({
displayName: "StarEmptyIcon",
d: "M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26ZM12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502L9.96214 9.69434L5.12921 10.2674L8.70231 13.5717L7.75383 18.3451L12.0006 15.968Z",
defaultProps,
});
export const StarFullIcon = createIcon({
displayName: "StarFullIcon",
d: "M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z",
defaultProps,
});
export const StarHalfIcon = createIcon({
displayName: "StarHalfIcon",
d: "M12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502V15.968ZM12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z",
defaultProps,
});

View File

@@ -1,17 +1,22 @@
import React, { useMemo } from "react";
import { Avatar, AvatarProps } from "@chakra-ui/react";
import { RelayIcon } from "./icons";
import { useRelayInfo } from "../hooks/use-relay-info";
export type RelayFaviconProps = Omit<AvatarProps, "src"> & {
relay: string;
};
export const RelayFavicon = React.memo(({ relay, ...props }: RelayFaviconProps) => {
const { info } = useRelayInfo(relay);
const url = useMemo(() => {
if (info?.icon) return info.icon;
const url = new URL(relay);
url.protocol = "https:";
url.pathname = "/favicon.ico";
return url.toString();
}, [relay]);
}, [relay, info]);
return <Avatar src={url} icon={<RelayIcon />} overflow="hidden" {...props} />;
});

View File

@@ -1,6 +1,5 @@
import { forwardRef, useState } from "react";
import {
Badge,
Button,
Flex,
IconButton,
@@ -31,9 +30,6 @@ function RelayPickerModal({
const { value: onlineRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
);
const { value: paidRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/paid").then((res) => res.json() as Promise<string[]>)
);
const relayList = unique(onlineRelays ?? []);
const filteredRelays = search ? relayList.filter((url) => url.includes(search)) : relayList;
@@ -57,20 +53,17 @@ function RelayPickerModal({
</InputGroup>
<Flex gap="2" direction="column">
{filteredRelays.map((url) => (
<Flex key={url} gap="2" alignItems="center">
<Button
value={url}
onClick={() => {
onSelect(url);
onClose();
}}
variant="outline"
size="sm"
>
{url}
</Button>
{paidRelays?.includes(url) && <Badge colorScheme="green">Paid</Badge>}
</Flex>
<Button
value={url}
onClick={() => {
onSelect(url);
onClose();
}}
variant="outline"
size="sm"
>
{url}
</Button>
))}
</Flex>
</ModalBody>

View File

@@ -0,0 +1,20 @@
import { Flex, IconProps } from "@chakra-ui/react";
import { StarEmptyIcon, StarFullIcon, StarHalfIcon } from "./icons";
export default function StarRating({ quality, stars = 5, ...props }: { quality: number; stars?: number } & IconProps) {
const normalized = Math.round(quality * (stars * 2)) / 2;
const renderStar = (i: number) => {
if (normalized >= i + 1) return <StarFullIcon {...props} />;
if (normalized === i + 0.5) return <StarHalfIcon {...props} />;
return <StarEmptyIcon {...props} />;
};
return (
<Flex gap="1">
{Array(stars)
.fill(0)
.map((_, i) => renderStar(i))}
</Flex>
);
}

View File

@@ -1,11 +1,5 @@
import debug from "debug";
import userMetadataService from "../services/user-metadata";
export const logger = debug("noStrudel");
debug.enable("noStrudel:*");
export function nameOrPubkey(pubkey: string) {
const parsed = userMetadataService.getSubject(pubkey).value;
return parsed?.name || parsed?.display_name || pubkey;
}

View File

@@ -3,6 +3,8 @@ export const convertToUrl = (url: string | URL) => (url instanceof URL ? url : n
export function normalizeRelayUrl(relayUrl: string) {
const url = new URL(relayUrl);
if (relayUrl.includes(",ws")) throw new Error("Can not have multiple relays in one string");
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
url.pathname = url.pathname.replace(/\/+/g, "/");

View File

@@ -62,6 +62,25 @@ class ClientRelayService {
this.relays.next(relays.relays);
}
async addRelay(url: string, mode: RelayMode) {
if (!this.relays.value.some((r) => r.url === url)) {
const newRelays = [...this.relays.value, { url, mode }];
await this.postUpdatedRelays(newRelays);
}
}
async updateRelay(url: string, mode: RelayMode) {
if (this.relays.value.some((r) => r.url === url)) {
const newRelays = this.relays.value.map((r) => (r.url === url ? { url, mode } : r));
await this.postUpdatedRelays(newRelays);
}
}
async removeRelay(url: string) {
if (this.relays.value.some((r) => r.url === url)) {
const newRelays = this.relays.value.filter((r) => r.url !== url);
await this.postUpdatedRelays(newRelays);
}
}
async postUpdatedRelays(newRelays: RelayConfig[]) {
const rTags: RTag[] = newRelays.map((r) => {
switch (r.mode) {
@@ -91,10 +110,11 @@ class ClientRelayService {
const event = await signingService.requestSignature(draft, current);
const results = nostrPostAction(writeUrls, event);
await results.onComplete;
// pass new event to the user relay service
userRelaysService.receiveEvent(event);
await results.onComplete;
}
getWriteUrls() {

View File

@@ -1,39 +1,41 @@
import db from "./db";
import { fetchWithCorsFallback } from "../helpers/cors";
export type RelayInformationDocument = {
name: string;
description: string;
icon?: string;
pubkey: string;
contact: string;
supported_nips: string;
supported_nips?: number[];
software: string;
version: string;
};
export type DnsIdentity = {
name: string;
domain: string;
pubkey: string;
relays: string[];
};
async function fetchInfo(relay: string) {
const url = new URL(relay);
url.protocol = url.protocol === "ws:" ? "http" : "https";
const infoDoc = await fetch(url, { headers: { Accept: "application/nostr+json" } }).then(
const infoDoc = await fetchWithCorsFallback(url, { headers: { Accept: "application/nostr+json" } }).then(
(res) => res.json() as Promise<RelayInformationDocument>
);
memoryCache.set(relay, infoDoc);
await db.put("relayInfo", infoDoc, relay);
return infoDoc;
}
const memoryCache = new Map<string, RelayInformationDocument>();
async function getInfo(relay: string) {
const cached = await db.get("relayInfo", relay);
if (cached) return cached;
if (memoryCache.has(relay)) return memoryCache.get(relay)!;
const cached = await db.get("relayInfo", relay);
if (cached) {
memoryCache.set(relay, cached);
return cached;
}
// TODO: if it fails, maybe cache a failure message
return fetchInfo(relay);
}
@@ -48,6 +50,7 @@ function dedupedGetIdentity(relay: string) {
}
export const relayInfoService = {
cache: memoryCache,
fetchInfo,
getInfo: dedupedGetIdentity,
};

View File

@@ -1,35 +1,41 @@
import { Relay } from "../classes/relay";
import Subject from "../classes/subject";
import { logger } from "../helpers/debug";
import { normalizeRelayUrl } from "../helpers/url";
export class RelayPoolService {
relays = new Map<string, Relay>();
relayClaims = new Map<string, Set<any>>();
onRelayCreated = new Subject<Relay>();
log = logger.extend("RelayPool");
getRelays() {
return Array.from(this.relays.values());
}
getRelayClaims(url: string) {
if (!this.relayClaims.has(url)) {
this.relayClaims.set(url, new Set());
const normalized = normalizeRelayUrl(url);
if (!this.relayClaims.has(normalized)) {
this.relayClaims.set(normalized, new Set());
}
return this.relayClaims.get(url) as Set<any>;
return this.relayClaims.get(normalized) as Set<any>;
}
requestRelay(url: string, connect = true) {
if (!this.relays.has(url)) {
const newRelay = new Relay(url);
this.relays.set(url, newRelay);
const normalized = normalizeRelayUrl(url);
if (!this.relays.has(normalized)) {
const newRelay = new Relay(normalized);
this.relays.set(normalized, newRelay);
this.onRelayCreated.next(newRelay);
}
const relay = this.relays.get(url) as Relay;
const relay = this.relays.get(normalized) as Relay;
if (connect && !relay.okay) {
try {
relay.open();
} catch (e) {
console.log(`Failed to connect to ${relay.url}`);
console.log(e);
this.log(`Failed to connect to ${relay.url}`);
this.log(e);
}
}
return relay;
@@ -50,8 +56,8 @@ export class RelayPoolService {
try {
relay.open();
} catch (e) {
console.log(`Failed to connect to ${relay.url}`);
console.log(e);
this.log(`Failed to connect to ${relay.url}`);
this.log(e);
}
}
}
@@ -59,10 +65,12 @@ export class RelayPoolService {
// id can be anything
addClaim(url: string, id: any) {
this.getRelayClaims(url).add(id);
const normalized = normalizeRelayUrl(url);
this.getRelayClaims(normalized).add(id);
}
removeClaim(url: string, id: any) {
this.getRelayClaims(url).delete(id);
const normalized = normalizeRelayUrl(url);
this.getRelayClaims(normalized).delete(id);
}
get connectedCount() {
@@ -79,7 +87,7 @@ const relayPoolService = new RelayPoolService();
setInterval(() => {
if (document.visibilityState === "visible") {
relayPoolService.reconnectRelays();
// relayPoolService.pruneRelays();
relayPoolService.pruneRelays();
}
}, 1000 * 15);

View File

@@ -5,8 +5,9 @@ import { SuperMap } from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import Subject from "../classes/subject";
import { NostrQuery } from "../types/nostr-query";
import { logger, nameOrPubkey } from "../helpers/debug";
import { logger } from "../helpers/debug";
import db from "./db";
import { nameOrPubkey } from "./user-metadata";
type Pubkey = string;
type Relay = string;

View File

@@ -50,4 +50,10 @@ if (import.meta.env.DEV) {
window.userMetadataService = userMetadataService;
}
// random helper for logging
export function nameOrPubkey(pubkey: string) {
const parsed = userMetadataService.getSubject(pubkey).value;
return parsed?.name || parsed?.display_name || pubkey;
}
export default userMetadataService;

View File

@@ -15,6 +15,8 @@ export type NostrQuery = {
"#p"?: string[];
"#d"?: string[];
"#t"?: string[];
"#r"?: string[];
"#l"?: string[];
"#g"?: string[];
since?: number;
until?: number;

View File

@@ -9,6 +9,8 @@ import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers
import useRelaysChanged from "../../hooks/use-relays-changed";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
import { useSearchParams } from "react-router-dom";
import { safeUrl } from "../../helpers/parse";
function GlobalPage() {
const readRelays = useRelaySelectionRelays();
@@ -44,9 +46,19 @@ function GlobalPage() {
}
export default function GlobalTab() {
const [params] = useSearchParams();
// wrap the global page with another relay selection so it dose not effect the rest of the app
let relays = ["wss://welcome.nostr.wine"];
const setRelay = params.get("relay");
if (setRelay) {
const url = safeUrl(setRelay);
relays = [setRelay];
}
return (
<RelaySelectionProvider overrideDefault={["wss://welcome.nostr.wine"]}>
<RelaySelectionProvider overrideDefault={relays}>
<GlobalPage />
</RelaySelectionProvider>
);

View File

@@ -1,161 +1,214 @@
import {
Box,
Button,
ButtonGroup,
Card,
CardBody,
CardHeader,
Divider,
Flex,
FormControl,
FormLabel,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
IconButton,
Heading,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalProps,
SimpleGrid,
Spacer,
Switch,
Text,
Badge,
useToast,
useDisclosure,
} from "@chakra-ui/react";
import { SyntheticEvent, useEffect, useState } from "react";
import { TrashIcon, UndoIcon } from "../../components/icons";
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 clientRelaysService from "../../services/client-relays";
import { RelayConfig, RelayMode } from "../../classes/relay";
import { useList } from "react-use";
import { RelayUrlInput } from "../../components/relay-url-input";
import { RelayMode } from "../../classes/relay";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { RelayStatus } from "../../components/relay-status";
import { normalizeRelayUrl } from "../../helpers/url";
import { RelayScoreBreakdown } from "../../components/relay-score-breakdown";
import RequireCurrentAccount from "../../providers/require-current-account";
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 RelaysPage() {
const relays = useSubject(clientRelaysService.relays);
const toast = useToast();
const [pendingAdd, addActions] = useList<RelayConfig>([]);
const [pendingRemove, removeActions] = useList<RelayConfig>([]);
useEffect(() => {
addActions.clear();
removeActions.clear();
}, [relays, addActions, removeActions]);
const [saving, setSaving] = useState(false);
const [relayInputValue, setRelayInputValue] = useState("");
const handleRemoveRelay = (relay: RelayConfig) => {
if (pendingAdd.includes(relay)) {
addActions.filter((r) => r !== relay);
} else if (pendingRemove.includes(relay)) {
removeActions.filter((r) => r !== relay);
} else {
removeActions.push(relay);
}
};
const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const url = normalizeRelayUrl(relayInputValue);
if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) {
addActions.push({ url, mode: RelayMode.ALL });
}
setRelayInputValue("");
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const savePending = async () => {
setSaving(true);
const newRelays = relays.concat(pendingAdd).filter((r) => !pendingRemove.includes(r));
await clientRelaysService.postUpdatedRelays(newRelays);
setSaving(false);
};
const hasPending = pendingAdd.length > 0 || pendingRemove.length > 0;
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 (
<Flex direction="column" pt="2" pb="2">
<TableContainer mb="4" overflowY="initial">
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Url</Th>
<Th>Score</Th>
<Th>Status</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{[...relays, ...pendingAdd].map((relay, i) => (
<Tr key={relay.url + i}>
<Td>
<Flex alignItems="center">
<RelayFavicon size="xs" relay={relay.url} mr="2" />
<Text>{relay.url}</Text>
</Flex>
</Td>
<Td>
<RelayScoreBreakdown relay={relay.url} />
</Td>
<Td>
<RelayStatus url={relay.url} />
</Td>
<Td isNumeric>
{pendingAdd.includes(relay) && (
<Badge colorScheme="green" mr="2">
Add
</Badge>
)}
{pendingRemove.includes(relay) && (
<Badge colorScheme="red" mr="2">
Remove
</Badge>
)}
<IconButton
icon={pendingRemove.includes(relay) ? <UndoIcon /> : <TrashIcon />}
title="Toggle Remove"
aria-label="Toggle Remove"
size="sm"
onClick={() => handleRemoveRelay(relay)}
isDisabled={saving}
/>
</Td>
</Tr>
<Card variant="outline">
<CardHeader display="flex" gap="2" px="2" pt="2" pb="0">
<UserAvatarLink pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Spacer />
<Text>{dayjs.unix(event.created_at).fromNow()}</Text>
</CardHeader>
<CardBody p="2" gap="2" display="flex" flexDirection="column">
{rating && <StarRating quality={rating.quality} color="yellow.400" />}
<Box whiteSpace="pre-wrap">{event.content}</Box>
</CardBody>
</Card>
);
}
function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit<ModalProps, "children">) {
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${relay}-reviews`, readRelays, {
kinds: [1985],
"#r": [relay],
"#l": ["review/relay"],
});
const events = useSubject(timeline.timeline);
return (
<Modal {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader p="4" pb="0">
{relay} reviews
</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" pt="0" pb="4">
<Flex gap="2" direction="column">
{events.map((event) => (
<RelayReviewNote key={event.id} event={event} />
))}
</Tbody>
</Table>
</TableContainer>
<form onSubmit={handleAddRelay}>
<FormControl>
<FormLabel htmlFor="relay-url-input">Add Relay</FormLabel>
<Flex gap="2">
<RelayUrlInput
id="relay-url-input"
value={relayInputValue}
onChange={(url) => setRelayInputValue(url)}
isRequired
/>
<Button type="submit" isDisabled={saving}>
Add
</Button>
</Flex>
</FormControl>
</form>
</ModalBody>
</ModalContent>
</Modal>
);
}
<Flex justifyContent="flex-end" gap="2">
<Button type="submit" isLoading={saving} onClick={savePending} isDisabled={!hasPending}>
Save Changes
</Button>
</Flex>
</Flex>
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 (
<>
<Card>
<CardHeader display="flex" gap="2" alignItems="center" p="2">
<RelayFavicon relay={url} size="xs" />
<Heading size="md" isTruncated>
{url}
</Heading>
</CardHeader>
<CardBody p="2" display="flex" flexDirection="column" gap="2">
{info?.pubkey && (
<Flex gap="2" alignItems="center">
<Text fontWeight="bold">Owner:</Text>
<UserAvatar pubkey={info.pubkey} size="xs" />
<UserLink pubkey={info.pubkey} />
<UserDnsIdentityIcon pubkey={info.pubkey} onlyIcon />
</Flex>
)}
<ButtonGroup size="sm">
{joined ? (
<Button
colorScheme="red"
variant="outline"
onClick={() => clientRelaysService.removeRelay(url)}
isDisabled={!account}
>
Leave
</Button>
) : (
<Button
colorScheme="green"
onClick={() => clientRelaysService.addRelay(url, RelayMode.ALL)}
isDisabled={!account}
>
Join
</Button>
)}
<Button onClick={reviewsModal.onOpen}>Reviews</Button>
<Button as={RouterLink} to={`/global?relay=${url}`}>
Notes
</Button>
<Button
as="a"
href={`https://nostr.watch/relay/${new URL(url).host}`}
target="_blank"
rightIcon={<ExternalLinkIcon />}
>
More info
</Button>
</ButtonGroup>
</CardBody>
</Card>
{reviewsModal.isOpen && <RelayReviewsModal isOpen onClose={reviewsModal.onClose} relay={url} size="2xl" />}
</>
);
}
export default function RelaysView() {
const [search, setSearch] = useState("");
const deboundedSearch = useDeferredValue(search);
const isSearching = deboundedSearch.length > 2;
const showAll = useDisclosure();
const clientRelays = useClientRelays().map((r) => r.url);
const discoveredRelays = relayPoolService
.getRelays()
.filter((r) => !clientRelays.includes(r.url))
.map((r) => r.url)
.filter(safeRelayUrl);
const { value: onlineRelays = [] } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
);
const filteredRelays = useMemo(() => {
if (isSearching) {
return onlineRelays.filter((url) => url.includes(deboundedSearch));
}
return showAll.isOpen ? onlineRelays : clientRelays;
}, [isSearching, deboundedSearch, onlineRelays, clientRelays, showAll.isOpen]);
return (
<RequireCurrentAccount>
<RelaysPage />
</RequireCurrentAccount>
<Flex direction="column" gap="2" p="2">
<Flex alignItems="center" gap="2">
<Input type="search" placeholder="search" value={search} onChange={(e) => setSearch(e.target.value)} w="auto" />
<Switch isChecked={showAll.isOpen} onChange={showAll.onToggle}>
Show All
</Switch>
</Flex>
<SimpleGrid minChildWidth="25rem" spacing="2">
{filteredRelays.map((url) => (
<RelayCard key={url} url={url} />
))}
</SimpleGrid>
{discoveredRelays && !isSearching && (
<>
<Divider />
<Heading size="lg">Discovered Relays</Heading>
<SimpleGrid minChildWidth="25rem" spacing="2">
{discoveredRelays.map((url) => (
<RelayCard key={url} url={url} />
))}
</SimpleGrid>
</>
)}
</Flex>
);
}