mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-29 11:12:12 +01:00
add NIP-66 relay stats service
This commit is contained in:
parent
aaa62088a1
commit
a39e6adf8c
5
.changeset/poor-donuts-admire.md
Normal file
5
.changeset/poor-donuts-admire.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add NIP-66 relay stats service
|
@ -16,7 +16,7 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import TimelineLoader from "../../classes/timeline-loader";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
import { safeRelayUrls } from "../../helpers/url";
|
||||
import { safeRelayUrls } from "../../helpers/relay";
|
||||
|
||||
const kindColors: Record<number, FlexProps["bg"]> = {
|
||||
[kinds.ShortTextNote]: "blue.500",
|
||||
|
@ -18,9 +18,9 @@ import {
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { RelayFavicon } from "../relay-favicon";
|
||||
import { RelayUrlInput } from "../relay-url-input";
|
||||
import { normalizeRelayUrl } from "../../helpers/url";
|
||||
import { unique } from "../../helpers/array";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
import { normalizeRelayURL } from "../../helpers/relay";
|
||||
|
||||
function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
|
||||
const [url, setUrl] = useState("");
|
||||
@ -32,7 +32,7 @@ function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
|
||||
onSubmit={(e) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
onSubmit(normalizeRelayUrl(url));
|
||||
onSubmit(normalizeRelayURL(url));
|
||||
setUrl("");
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
|
@ -5,8 +5,8 @@ import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatar from "../../user-avatar";
|
||||
import UserLink from "../../user-link";
|
||||
import RelayCard from "../../../views/relays/components/relay-card";
|
||||
import { safeRelayUrl } from "../../../helpers/url";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
|
||||
import { safeRelayUrl } from "../../../helpers/relay";
|
||||
|
||||
export default function RelayRecommendation({ event }: { event: NostrEvent }) {
|
||||
const safeUrl = safeRelayUrl(event.content);
|
||||
|
44
src/helpers/nostr/relay-stats.ts
Normal file
44
src/helpers/nostr/relay-stats.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { NostrEvent, isDTag } from "../../types/nostr-event";
|
||||
|
||||
export const SELF_REPORTED_KIND = 10066;
|
||||
export const MONITOR_METADATA_KIND = 10166;
|
||||
export const MONITOR_STATS_KIND = 30066;
|
||||
|
||||
export function getRelayURL(stats: NostrEvent) {
|
||||
if (stats.kind === SELF_REPORTED_KIND) return stats.tags.find((t) => t[0] === "r")?.[1];
|
||||
return stats.tags.find(isDTag)?.[1];
|
||||
}
|
||||
export function getNetwork(stats: NostrEvent) {
|
||||
return stats.tags.find((t) => t[0] === "n")?.[1];
|
||||
}
|
||||
export function getSupportedNIPs(stats: NostrEvent) {
|
||||
return stats.tags.filter((t) => t[0] === "N" && t[1]).map((t) => t[1] && parseInt(t[1]));
|
||||
}
|
||||
|
||||
type RTTValues = {
|
||||
min: number;
|
||||
max?: number;
|
||||
average?: number;
|
||||
median?: number;
|
||||
};
|
||||
function getRTTTag(stats: NostrEvent, name: string): RTTValues | undefined {
|
||||
const values = stats.tags
|
||||
.find((t) => t[0] === "rtt" && t[1] === "open")
|
||||
?.slice(2)
|
||||
.map((v) => parseInt(v));
|
||||
if (!values || values.length === 0) return undefined;
|
||||
|
||||
return {
|
||||
min: values[0],
|
||||
max: values[1],
|
||||
average: values[2],
|
||||
median: values[3],
|
||||
};
|
||||
}
|
||||
export function getRTT(stats: NostrEvent) {
|
||||
const open = getRTTTag(stats, "open");
|
||||
const read = getRTTTag(stats, "read");
|
||||
const write = getRTTTag(stats, "write");
|
||||
|
||||
return { open, read, write };
|
||||
}
|
@ -1,10 +1,49 @@
|
||||
import { SimpleRelay, SimpleSubscription, SimpleSubscriptionOptions } from "nostr-idb";
|
||||
import { RelayConfig } from "../classes/relay";
|
||||
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
|
||||
import { safeRelayUrl } from "./url";
|
||||
import { Filter } from "nostr-tools";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export function validateRelayURL(relay: string) {
|
||||
if (relay.includes(",ws")) throw new Error("Can not have multiple relays in one string");
|
||||
const url = new URL(relay);
|
||||
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
|
||||
return url;
|
||||
}
|
||||
export function isValidRelayURL(relay: string) {
|
||||
try {
|
||||
validateRelayURL(relay);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRelayURL(relayUrl: string) {
|
||||
const url = validateRelayURL(relayUrl);
|
||||
url.pathname = url.pathname.replace(/\/+/g, "/");
|
||||
if ((url.port === "80" && url.protocol === "ws:") || (url.port === "443" && url.protocol === "wss:")) url.port = "";
|
||||
url.searchParams.sort();
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
export function safeNormalizeRelayURL(relayUrl: string) {
|
||||
try {
|
||||
return normalizeRelayURL(relayUrl);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: move these to helpers/relay
|
||||
export function safeRelayUrl(relayUrl: string) {
|
||||
if (isValidRelayURL(relayUrl)) return new URL(relayUrl).toString();
|
||||
return null;
|
||||
}
|
||||
export function safeRelayUrls(urls: string[]): string[] {
|
||||
return urls.map(safeRelayUrl).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function normalizeRelayConfigs(relays: RelayConfig[]) {
|
||||
const seen: string[] = [];
|
||||
return relays.reduce((newArr, r) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { URLSearchParamsInit } from "react-router-dom";
|
||||
import { isValidRelayURL, validateRelayURL } from "./relay";
|
||||
|
||||
export const convertToUrl = (url: string | URL) => (url instanceof URL ? url : new URL(url));
|
||||
|
||||
@ -22,33 +22,6 @@ export function isAudioURL(url: string | URL) {
|
||||
return AUDIO_EXT.some((ext) => u.pathname.endsWith(ext));
|
||||
}
|
||||
|
||||
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, "/");
|
||||
if (url.pathname.endsWith("/")) url.pathname = url.pathname.slice(0, -1);
|
||||
if ((url.port === "80" && url.protocol === "ws:") || (url.port === "443" && url.protocol === "wss:")) url.port = "";
|
||||
url.searchParams.sort();
|
||||
url.hash = "";
|
||||
|
||||
return url.origin + (url.pathname === "/" ? "" : url.pathname) + url.search;
|
||||
}
|
||||
|
||||
export function safeRelayUrl(relayUrl: string) {
|
||||
try {
|
||||
return normalizeRelayUrl(relayUrl);
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function safeRelayUrls(urls: string[]): string[] {
|
||||
return urls.map(safeRelayUrl).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function replaceDomain(url: string | URL, replacementUrl: string | URL) {
|
||||
const newUrl = new URL(url);
|
||||
replacementUrl = convertToUrl(replacementUrl);
|
||||
|
17
src/hooks/use-relay-stats.ts
Normal file
17
src/hooks/use-relay-stats.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import relayStatsService from "../services/relay-stats";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export default function useRelayStats(relay: string) {
|
||||
const monitorSub = relayStatsService.requestMonitorStats(relay);
|
||||
const selfReportedSub = relayStatsService.requestSelfReported(relay);
|
||||
|
||||
const monitor = useSubject(monitorSub);
|
||||
const selfReported = useSubject(selfReportedSub);
|
||||
const stats = monitor || selfReported || undefined;
|
||||
|
||||
return {
|
||||
monitor,
|
||||
selfReported,
|
||||
stats,
|
||||
};
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import React, { useContext } from "react";
|
||||
import { unique } from "../../helpers/array";
|
||||
import { safeRelayUrl } from "../../helpers/url";
|
||||
import { safeRelayUrls } from "../../helpers/relay";
|
||||
|
||||
export const RelayContext = React.createContext<string[]>([]);
|
||||
|
||||
@ -18,7 +18,7 @@ export function AdditionalRelayProvider({
|
||||
extend?: boolean;
|
||||
}) {
|
||||
const parentRelays = useAdditionalRelayContext();
|
||||
const safeUrls = (extend ? [...parentRelays, ...relays] : relays).map(safeRelayUrl).filter(Boolean) as string[];
|
||||
const safeUrls = safeRelayUrls(extend ? [...parentRelays, ...relays] : relays);
|
||||
|
||||
return <RelayContext.Provider value={unique(safeUrls)}>{children}</RelayContext.Provider>;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import Relay from "../classes/relay";
|
||||
import Subject from "../classes/subject";
|
||||
import { logger } from "../helpers/debug";
|
||||
import { normalizeRelayUrl } from "../helpers/url";
|
||||
import { normalizeRelayURL } from "../helpers/relay";
|
||||
|
||||
export class RelayPoolService {
|
||||
relays = new Map<string, Relay>();
|
||||
@ -14,7 +14,7 @@ export class RelayPoolService {
|
||||
return Array.from(this.relays.values());
|
||||
}
|
||||
getRelayClaims(url: string) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
const normalized = normalizeRelayURL(url);
|
||||
if (!this.relayClaims.has(normalized)) {
|
||||
this.relayClaims.set(normalized, new Set());
|
||||
}
|
||||
@ -22,7 +22,7 @@ export class RelayPoolService {
|
||||
}
|
||||
|
||||
requestRelay(url: string, connect = true) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
const normalized = normalizeRelayURL(url);
|
||||
if (!this.relays.has(normalized)) {
|
||||
const newRelay = new Relay(normalized);
|
||||
this.relays.set(normalized, newRelay);
|
||||
@ -65,11 +65,11 @@ export class RelayPoolService {
|
||||
|
||||
// id can be anything
|
||||
addClaim(url: string, id: any) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
const normalized = normalizeRelayURL(url);
|
||||
this.getRelayClaims(normalized).add(id);
|
||||
}
|
||||
removeClaim(url: string, id: any) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
const normalized = normalizeRelayURL(url);
|
||||
this.getRelayClaims(normalized).delete(id);
|
||||
}
|
||||
|
||||
|
95
src/services/relay-stats.ts
Normal file
95
src/services/relay-stats.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import _throttle from "lodash/throttle";
|
||||
|
||||
import NostrRequest from "../classes/nostr-request";
|
||||
import Subject from "../classes/subject";
|
||||
import SuperMap from "../classes/super-map";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import relayInfoService from "./relay-info";
|
||||
import { normalizeRelayURL } from "../helpers/relay";
|
||||
import { localCacheRelay } from "./local-cache-relay";
|
||||
import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats";
|
||||
|
||||
const MONITOR_PUBKEY = "151c17c9d234320cf0f189af7b761f63419fd6c38c6041587a008b7682e4640f";
|
||||
const MONITOR_RELAY = "wss://history.nostr.watch";
|
||||
|
||||
class RelayStatsService {
|
||||
private selfReported = new SuperMap<string, Subject<NostrEvent | null>>(() => new Subject());
|
||||
private monitorStats = new SuperMap<string, Subject<NostrEvent>>(() => new Subject());
|
||||
|
||||
constructor() {
|
||||
// load all stats from cache and subscribe to future ones
|
||||
localCacheRelay.subscribe([{ kinds: [SELF_REPORTED_KIND, MONITOR_STATS_KIND] }], {
|
||||
onevent: (e) => this.handleEvent(e, false),
|
||||
});
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent, cache = true) {
|
||||
// ignore all events before NIP-66 start date
|
||||
if (event.created_at < 1704196800) return;
|
||||
|
||||
const relay = getRelayURL(event);
|
||||
if (!relay) return;
|
||||
|
||||
const sub = this.monitorStats.get(relay);
|
||||
if (event.kind === SELF_REPORTED_KIND) {
|
||||
if (!sub.value || event.created_at > sub.value.created_at) {
|
||||
sub.next(event);
|
||||
if (cache) localCacheRelay.publish(event);
|
||||
}
|
||||
} else if (event.kind === MONITOR_STATS_KIND) {
|
||||
if (!sub.value || event.created_at > sub.value.created_at) {
|
||||
sub.next(event);
|
||||
if (cache) localCacheRelay.publish(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestSelfReported(relay: string) {
|
||||
relay = normalizeRelayURL(relay);
|
||||
const sub = this.selfReported.get(relay);
|
||||
|
||||
if (sub.value === undefined) {
|
||||
relayInfoService.getInfo(relay).then((info) => {
|
||||
if (!info.pubkey) return sub.next(null);
|
||||
|
||||
const request = new NostrRequest([relay, MONITOR_RELAY]);
|
||||
request.onEvent.subscribe(this.handleEvent, this);
|
||||
request.start({ kinds: [SELF_REPORTED_KIND], authors: [info.pubkey] });
|
||||
});
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
requestMonitorStats(relay: string) {
|
||||
relay = normalizeRelayURL(relay);
|
||||
const sub = this.monitorStats.get(relay);
|
||||
|
||||
if (sub.value === undefined) {
|
||||
this.pendingMonitorStats.add(relay);
|
||||
this.throttleBatchRequestMonitorStats();
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
throttleBatchRequestMonitorStats = _throttle(this.batchRequestMonitorStats, 200);
|
||||
pendingMonitorStats = new Set<string>();
|
||||
private batchRequestMonitorStats() {
|
||||
const relays = Array.from(this.pendingMonitorStats);
|
||||
|
||||
const request = new NostrRequest([MONITOR_RELAY]);
|
||||
request.onEvent.subscribe(this.handleEvent, this);
|
||||
request.start({ since: 1704196800, kinds: [MONITOR_STATS_KIND], "#d": relays, authors: [MONITOR_PUBKEY] });
|
||||
|
||||
this.pendingMonitorStats.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const relayStatsService = new RelayStatsService();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
//@ts-ignore
|
||||
window.relayStatsService = relayStatsService;
|
||||
}
|
||||
|
||||
export default relayStatsService;
|
@ -3,10 +3,9 @@ import _throttle from "lodash.throttle";
|
||||
import NostrRequest from "../classes/nostr-request";
|
||||
import Subject from "../classes/subject";
|
||||
import SuperMap from "../classes/super-map";
|
||||
import { safeRelayUrls } from "../helpers/url";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { localCacheRelay } from "./local-cache-relay";
|
||||
import { relayRequest } from "../helpers/relay";
|
||||
import { relayRequest, safeRelayUrls } from "../helpers/relay";
|
||||
import { logger } from "../helpers/debug";
|
||||
|
||||
const RELAY_REQUEST_BATCH_TIME = 500;
|
||||
@ -20,8 +19,8 @@ class SingleEventService {
|
||||
const subject = this.cache.get(id);
|
||||
if (subject.value) return subject;
|
||||
|
||||
const newUrls = safeRelayUrls(relays);
|
||||
this.pending.set(id, this.pending.get(id)?.concat(newUrls) ?? newUrls);
|
||||
relays = safeRelayUrls(relays);
|
||||
this.pending.set(id, this.pending.get(id)?.concat(relays) ?? relays);
|
||||
this.batchRequestsThrottle();
|
||||
|
||||
return subject;
|
||||
|
@ -19,9 +19,6 @@ import {
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
useToast,
|
||||
@ -36,11 +33,11 @@ import Upload01 from "../../../components/icons/upload-01";
|
||||
import { nostrBuildUploadImage } from "../../../helpers/nostr-build";
|
||||
import { useSigningContext } from "../../../providers/global/signing-provider";
|
||||
import { RelayUrlInput } from "../../../components/relay-url-input";
|
||||
import { safeRelayUrl } from "../../../helpers/url";
|
||||
import { RelayFavicon } from "../../../components/relay-favicon";
|
||||
import NpubAutocomplete from "../../../components/npub-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} />;
|
||||
|
@ -23,11 +23,11 @@ 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";
|
||||
import { safeRelayUrl } from "../../../helpers/relay";
|
||||
|
||||
function RelayDetails({ url, debug }: { url: string; debug?: boolean }) {
|
||||
const { info } = useRelayInfo(url);
|
||||
|
@ -38,6 +38,8 @@ import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import RawJson from "../../../components/debug-modals/raw-json";
|
||||
import { RelayShareButton } from "./relay-share-button";
|
||||
import useRelayStats from "../../../hooks/use-relay-stats";
|
||||
import { getNetwork } from "../../../helpers/nostr/relay-stats";
|
||||
|
||||
const B = styled.span`
|
||||
font-weight: bold;
|
||||
@ -52,6 +54,7 @@ export const Metadata = ({ name, children }: { name: string } & PropsWithChildre
|
||||
|
||||
export function RelayMetadata({ url, extended }: { url: string; extended?: boolean }) {
|
||||
const { info } = useRelayInfo(url);
|
||||
const { stats } = useRelayStats(url);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@ -66,6 +69,7 @@ export function RelayMetadata({ url, extended }: { url: string; extended?: boole
|
||||
)}
|
||||
{extended && (
|
||||
<>
|
||||
{stats && <Metadata name="Network">{getNetwork(stats)}</Metadata>}
|
||||
<Metadata name="Software">{info?.software}</Metadata>
|
||||
<Metadata name="Version">{info?.version}</Metadata>
|
||||
</>
|
||||
|
@ -5,13 +5,13 @@ import { Button, Divider, Flex, Heading, Input, SimpleGrid, Spacer, Switch, useD
|
||||
|
||||
import { useClientRelays } from "../../hooks/use-client-relays";
|
||||
import relayPoolService from "../../services/relay-pool";
|
||||
import { safeRelayUrl } from "../../helpers/url";
|
||||
import AddCustomRelayModal from "./components/add-custom-modal";
|
||||
import RelayCard from "./components/relay-card";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { RelayMode } from "../../classes/relay";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { isValidRelayURL } from "../../helpers/relay";
|
||||
|
||||
export default function RelaysView() {
|
||||
const [search, setSearch] = useState("");
|
||||
@ -24,7 +24,7 @@ export default function RelaysView() {
|
||||
.getRelays()
|
||||
.filter((r) => !clientRelays.includes(r.url))
|
||||
.map((r) => r.url)
|
||||
.filter(safeRelayUrl);
|
||||
.filter(isValidRelayURL);
|
||||
|
||||
const { value: onlineRelays = [] } = useAsync(async () =>
|
||||
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>),
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { safeRelayUrl } from "../../../helpers/url";
|
||||
import { useRelayInfo } from "../../../hooks/use-relay-info";
|
||||
import { RelayDebugButton, RelayJoinAction, RelayMetadata } from "../components/relay-card";
|
||||
import SupportedNIPs from "../components/supported-nips";
|
||||
@ -26,6 +25,7 @@ import PeopleListProvider from "../../../providers/local/people-list-provider";
|
||||
import PeopleListSelection from "../../../components/people-list-selection/people-list-selection";
|
||||
import { RelayFavicon } from "../../../components/relay-favicon";
|
||||
import VerticalPageLayout from "../../../components/vertical-page-layout";
|
||||
import { safeRelayUrl } from "../../../helpers/relay";
|
||||
const RelayDetailsTab = lazy(() => import("./relay-details"));
|
||||
|
||||
function RelayPage({ relay }: { relay: string }) {
|
||||
@ -104,7 +104,6 @@ export default function RelayView() {
|
||||
if (!relay) return <>No relay url</>;
|
||||
|
||||
const safeUrl = safeRelayUrl(relay);
|
||||
|
||||
if (!safeUrl) return <>Bad relay url</>;
|
||||
|
||||
return (
|
||||
|
@ -28,7 +28,7 @@ import {
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
} from "chart.js";
|
||||
import { Line, Pie } from "react-chartjs-2";
|
||||
import { Pie } from "react-chartjs-2";
|
||||
import _throttle from "lodash.throttle";
|
||||
|
||||
import { useAppTitle } from "../../../hooks/use-app-title";
|
||||
|
Loading…
x
Reference in New Issue
Block a user