From 33da3e2fa99b6f1dd1f34a4c9d73b384b14703e2 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Thu, 3 Aug 2023 22:01:20 -0500 Subject: [PATCH] rebuild relay view --- .changeset/violet-houses-switch.md | 5 + src/app.tsx | 2 +- src/components/connected-relays.tsx | 2 +- src/components/icons.tsx | 18 ++ src/components/relay-favicon.tsx | 7 +- src/components/relay-url-input.tsx | 29 +- src/components/star-rating.tsx | 20 ++ src/helpers/debug.ts | 6 - src/helpers/url.ts | 2 + src/services/client-relays.ts | 22 +- src/services/relay-info.ts | 25 +- src/services/relay-pool.ts | 36 ++- src/services/replaceable-event-requester.ts | 3 +- src/services/user-metadata.ts | 6 + src/types/nostr-query.ts | 2 + src/views/home/global-tab.tsx | 14 +- src/views/relays/index.tsx | 333 ++++++++++++-------- 17 files changed, 337 insertions(+), 195 deletions(-) create mode 100644 .changeset/violet-houses-switch.md create mode 100644 src/components/star-rating.tsx diff --git a/.changeset/violet-houses-switch.md b/.changeset/violet-houses-switch.md new file mode 100644 index 000000000..eb1dc856c --- /dev/null +++ b/.changeset/violet-houses-switch.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Rebuild relay view and show relay reviews diff --git a/src/app.tsx b/src/app.tsx index 9c77bd80a..61c61c04a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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")); diff --git a/src/components/connected-relays.tsx b/src/components/connected-relays.tsx index e3cb7bf34..719e47d8a 100644 --- a/src/components/connected-relays.tsx +++ b/src/components/connected-relays.tsx @@ -43,7 +43,7 @@ export const ConnectedRelays = () => { return ( <> diff --git a/src/components/icons.tsx b/src/components/icons.tsx index ebce45a24..c51820633 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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, +}); diff --git a/src/components/relay-favicon.tsx b/src/components/relay-favicon.tsx index 73a83e8fd..3ff9e921f 100644 --- a/src/components/relay-favicon.tsx +++ b/src/components/relay-favicon.tsx @@ -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 & { 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 } overflow="hidden" {...props} />; }); diff --git a/src/components/relay-url-input.tsx b/src/components/relay-url-input.tsx index 06f537a6b..a2bfe3f59 100644 --- a/src/components/relay-url-input.tsx +++ b/src/components/relay-url-input.tsx @@ -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) ); - const { value: paidRelays } = useAsync(async () => - fetch("https://api.nostr.watch/v1/paid").then((res) => res.json() as Promise) - ); const relayList = unique(onlineRelays ?? []); const filteredRelays = search ? relayList.filter((url) => url.includes(search)) : relayList; @@ -57,20 +53,17 @@ function RelayPickerModal({ {filteredRelays.map((url) => ( - - - {paidRelays?.includes(url) && Paid} - + ))} diff --git a/src/components/star-rating.tsx b/src/components/star-rating.tsx new file mode 100644 index 000000000..18be54bee --- /dev/null +++ b/src/components/star-rating.tsx @@ -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 ; + if (normalized === i + 0.5) return ; + return ; + }; + + return ( + + {Array(stars) + .fill(0) + .map((_, i) => renderStar(i))} + + ); +} diff --git a/src/helpers/debug.ts b/src/helpers/debug.ts index ed31ee0ac..afad22867 100644 --- a/src/helpers/debug.ts +++ b/src/helpers/debug.ts @@ -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; -} diff --git a/src/helpers/url.ts b/src/helpers/url.ts index 314f14adc..910cbfbb1 100644 --- a/src/helpers/url.ts +++ b/src/helpers/url.ts @@ -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, "/"); diff --git a/src/services/client-relays.ts b/src/services/client-relays.ts index e0ea25704..cf0e6d8a2 100644 --- a/src/services/client-relays.ts +++ b/src/services/client-relays.ts @@ -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() { diff --git a/src/services/relay-info.ts b/src/services/relay-info.ts index d2c62c868..71a29382f 100644 --- a/src/services/relay-info.ts +++ b/src/services/relay-info.ts @@ -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 ); + memoryCache.set(relay, infoDoc); await db.put("relayInfo", infoDoc, relay); return infoDoc; } +const memoryCache = new Map(); 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, }; diff --git a/src/services/relay-pool.ts b/src/services/relay-pool.ts index 9416c6c4b..b55112f5b 100644 --- a/src/services/relay-pool.ts +++ b/src/services/relay-pool.ts @@ -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(); relayClaims = new Map>(); onRelayCreated = new Subject(); + 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; + return this.relayClaims.get(normalized) as Set; } 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); diff --git a/src/services/replaceable-event-requester.ts b/src/services/replaceable-event-requester.ts index 70e1baffb..2edebd773 100644 --- a/src/services/replaceable-event-requester.ts +++ b/src/services/replaceable-event-requester.ts @@ -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; diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts index 49bef6a92..5800c9324 100644 --- a/src/services/user-metadata.ts +++ b/src/services/user-metadata.ts @@ -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; diff --git a/src/types/nostr-query.ts b/src/types/nostr-query.ts index 0ef489715..dd08a8b94 100644 --- a/src/types/nostr-query.ts +++ b/src/types/nostr-query.ts @@ -15,6 +15,8 @@ export type NostrQuery = { "#p"?: string[]; "#d"?: string[]; "#t"?: string[]; + "#r"?: string[]; + "#l"?: string[]; "#g"?: string[]; since?: number; until?: number; diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index 9367667bf..5c32f11d1 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -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 ( - + ); diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 0f74f47c2..befc23fe7 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -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([]); - const [pendingRemove, removeActions] = useList([]); - - 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) => { - 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 ( - - - - - - - - - - - - - {[...relays, ...pendingAdd].map((relay, i) => ( - - - - - - + + + + + + + {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) => ( + ))} - -
UrlScoreStatus
- - - {relay.url} - - - - - - - {pendingAdd.includes(relay) && ( - - Add - - )} - {pendingRemove.includes(relay) && ( - - Remove - - )} - : } - title="Toggle Remove" - aria-label="Toggle Remove" - size="sm" - onClick={() => handleRemoveRelay(relay)} - isDisabled={saving} - /> -
-
- -
- - Add Relay - - setRelayInputValue(url)} - isRequired - /> - - -
+ + +
+ ); +} - - - - +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 ? ( + + ) : ( + + )} + + + + + + + {reviewsModal.isOpen && } + ); } 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) + ); + + const filteredRelays = useMemo(() => { + if (isSearching) { + return onlineRelays.filter((url) => url.includes(deboundedSearch)); + } + + return showAll.isOpen ? onlineRelays : clientRelays; + }, [isSearching, deboundedSearch, onlineRelays, clientRelays, showAll.isOpen]); + return ( - - - + + + setSearch(e.target.value)} w="auto" /> + + Show All + + + + {filteredRelays.map((url) => ( + + ))} + + + {discoveredRelays && !isSearching && ( + <> + + Discovered Relays + + {discoveredRelays.map((url) => ( + + ))} + + + )} + ); }