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 LoginStartView from "./views/login/start";
import LoginNpubView from "./views/login/npub"; import LoginNpubView from "./views/login/npub";
import NotificationsView from "./views/notifications"; import NotificationsView from "./views/notifications";
import RelaysView from "./views/relays";
import LoginNip05View from "./views/login/nip05"; import LoginNip05View from "./views/login/nip05";
import LoginNsecView from "./views/login/nsec"; import LoginNsecView from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps"; 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 useSetColorMode from "./hooks/use-set-color-mode";
import UserStreamsTab from "./views/user/streams"; import UserStreamsTab from "./views/user/streams";
import { PageProviders } from "./providers"; import { PageProviders } from "./providers";
import RelaysView from "./views/relays";
const StreamsView = React.lazy(() => import("./views/streams")); const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream")); const StreamView = React.lazy(() => import("./views/streams/stream"));

View File

@@ -43,7 +43,7 @@ export const ConnectedRelays = () => {
return ( return (
<> <>
<Button variant="link" onClick={onOpen} leftIcon={<RelayIcon />}> <Button variant="link" onClick={onOpen} leftIcon={<RelayIcon />}>
{connected.length}/{relays.length} of relays connected {connected.length} relays connected
</Button> </Button>
<Modal isOpen={isOpen} onClose={onClose} size="5xl"> <Modal isOpen={isOpen} onClose={onClose} size="5xl">
<ModalOverlay /> <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", 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, 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 React, { useMemo } from "react";
import { Avatar, AvatarProps } from "@chakra-ui/react"; import { Avatar, AvatarProps } from "@chakra-ui/react";
import { RelayIcon } from "./icons"; import { RelayIcon } from "./icons";
import { useRelayInfo } from "../hooks/use-relay-info";
export type RelayFaviconProps = Omit<AvatarProps, "src"> & { export type RelayFaviconProps = Omit<AvatarProps, "src"> & {
relay: string; relay: string;
}; };
export const RelayFavicon = React.memo(({ relay, ...props }: RelayFaviconProps) => { export const RelayFavicon = React.memo(({ relay, ...props }: RelayFaviconProps) => {
const { info } = useRelayInfo(relay);
const url = useMemo(() => { const url = useMemo(() => {
if (info?.icon) return info.icon;
const url = new URL(relay); const url = new URL(relay);
url.protocol = "https:"; url.protocol = "https:";
url.pathname = "/favicon.ico"; url.pathname = "/favicon.ico";
return url.toString(); return url.toString();
}, [relay]); }, [relay, info]);
return <Avatar src={url} icon={<RelayIcon />} overflow="hidden" {...props} />; return <Avatar src={url} icon={<RelayIcon />} overflow="hidden" {...props} />;
}); });

View File

@@ -1,6 +1,5 @@
import { forwardRef, useState } from "react"; import { forwardRef, useState } from "react";
import { import {
Badge,
Button, Button,
Flex, Flex,
IconButton, IconButton,
@@ -31,9 +30,6 @@ function RelayPickerModal({
const { value: onlineRelays } = useAsync(async () => const { value: onlineRelays } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>) 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 relayList = unique(onlineRelays ?? []);
const filteredRelays = search ? relayList.filter((url) => url.includes(search)) : relayList; const filteredRelays = search ? relayList.filter((url) => url.includes(search)) : relayList;
@@ -57,7 +53,6 @@ function RelayPickerModal({
</InputGroup> </InputGroup>
<Flex gap="2" direction="column"> <Flex gap="2" direction="column">
{filteredRelays.map((url) => ( {filteredRelays.map((url) => (
<Flex key={url} gap="2" alignItems="center">
<Button <Button
value={url} value={url}
onClick={() => { onClick={() => {
@@ -69,8 +64,6 @@ function RelayPickerModal({
> >
{url} {url}
</Button> </Button>
{paidRelays?.includes(url) && <Badge colorScheme="green">Paid</Badge>}
</Flex>
))} ))}
</Flex> </Flex>
</ModalBody> </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 debug from "debug";
import userMetadataService from "../services/user-metadata";
export const logger = debug("noStrudel"); export const logger = debug("noStrudel");
debug.enable("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) { export function normalizeRelayUrl(relayUrl: string) {
const url = new URL(relayUrl); 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"); if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
url.pathname = url.pathname.replace(/\/+/g, "/"); url.pathname = url.pathname.replace(/\/+/g, "/");

View File

@@ -62,6 +62,25 @@ class ClientRelayService {
this.relays.next(relays.relays); 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[]) { async postUpdatedRelays(newRelays: RelayConfig[]) {
const rTags: RTag[] = newRelays.map((r) => { const rTags: RTag[] = newRelays.map((r) => {
switch (r.mode) { switch (r.mode) {
@@ -91,10 +110,11 @@ class ClientRelayService {
const event = await signingService.requestSignature(draft, current); const event = await signingService.requestSignature(draft, current);
const results = nostrPostAction(writeUrls, event); const results = nostrPostAction(writeUrls, event);
await results.onComplete;
// pass new event to the user relay service // pass new event to the user relay service
userRelaysService.receiveEvent(event); userRelaysService.receiveEvent(event);
await results.onComplete;
} }
getWriteUrls() { getWriteUrls() {

View File

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

View File

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

View File

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

View File

@@ -50,4 +50,10 @@ if (import.meta.env.DEV) {
window.userMetadataService = userMetadataService; 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; export default userMetadataService;

View File

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

View File

@@ -9,6 +9,8 @@ import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers
import useRelaysChanged from "../../hooks/use-relays-changed"; import useRelaysChanged from "../../hooks/use-relays-changed";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page"; import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type"; import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
import { useSearchParams } from "react-router-dom";
import { safeUrl } from "../../helpers/parse";
function GlobalPage() { function GlobalPage() {
const readRelays = useRelaySelectionRelays(); const readRelays = useRelaySelectionRelays();
@@ -44,9 +46,19 @@ function GlobalPage() {
} }
export default function GlobalTab() { 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 // 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 ( return (
<RelaySelectionProvider overrideDefault={["wss://welcome.nostr.wine"]}> <RelaySelectionProvider overrideDefault={relays}>
<GlobalPage /> <GlobalPage />
</RelaySelectionProvider> </RelaySelectionProvider>
); );

View File

@@ -1,161 +1,214 @@
import { import {
Box,
Button, Button,
ButtonGroup,
Card,
CardBody,
CardHeader,
Divider,
Flex, Flex,
FormControl, Heading,
FormLabel, Input,
Table, Modal,
Thead, ModalBody,
Tbody, ModalCloseButton,
Tr, ModalContent,
Th, ModalHeader,
Td, ModalOverlay,
TableContainer, ModalProps,
IconButton, SimpleGrid,
Spacer,
Switch,
Text, Text,
Badge, useDisclosure,
useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { SyntheticEvent, useEffect, useState } from "react"; import { useAsync } from "react-use";
import { TrashIcon, UndoIcon } from "../../components/icons"; import { Link as RouterLink } from "react-router-dom";
import { useRelayInfo } from "../../hooks/use-relay-info";
import { RelayFavicon } from "../../components/relay-favicon"; 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 clientRelaysService from "../../services/client-relays";
import { RelayConfig, RelayMode } from "../../classes/relay"; import { RelayMode } from "../../classes/relay";
import { useList } from "react-use"; import useTimelineLoader from "../../hooks/use-timeline-loader";
import { RelayUrlInput } from "../../components/relay-url-input";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { RelayStatus } from "../../components/relay-status"; import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { normalizeRelayUrl } from "../../helpers/url"; import { UserAvatarLink } from "../../components/user-avatar-link";
import { RelayScoreBreakdown } from "../../components/relay-score-breakdown"; import { NostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account"; 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() { function RelayReviewNote({ event }: { event: NostrEvent }) {
const relays = useSubject(clientRelaysService.relays); const ratingJson = event.tags.find((t) => t[0] === "l" && t[3])?.[3];
const toast = useToast(); const rating = ratingJson ? (safeJson(ratingJson, undefined) as { quality: number } | undefined) : undefined;
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;
return ( return (
<Flex direction="column" pt="2" pb="2"> <Card variant="outline">
<TableContainer mb="4" overflowY="initial"> <CardHeader display="flex" gap="2" px="2" pt="2" pb="0">
<Table variant="simple" size="sm"> <UserAvatarLink pubkey={event.pubkey} size="xs" />
<Thead> <UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<Tr> <UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Th>Url</Th> <Spacer />
<Th>Score</Th> <Text>{dayjs.unix(event.created_at).fromNow()}</Text>
<Th>Status</Th> </CardHeader>
<Th></Th> <CardBody p="2" gap="2" display="flex" flexDirection="column">
</Tr> {rating && <StarRating quality={rating.quality} color="yellow.400" />}
</Thead> <Box whiteSpace="pre-wrap">{event.content}</Box>
<Tbody> </CardBody>
{[...relays, ...pendingAdd].map((relay, i) => ( </Card>
<Tr key={relay.url + i}> );
<Td> }
<Flex alignItems="center"> function RelayReviewsModal({ relay, ...props }: { relay: string } & Omit<ModalProps, "children">) {
<RelayFavicon size="xs" relay={relay.url} mr="2" /> const readRelays = useReadRelayUrls();
<Text>{relay.url}</Text> const timeline = useTimelineLoader(`${relay}-reviews`, readRelays, {
</Flex> kinds: [1985],
</Td> "#r": [relay],
<Td> "#l": ["review/relay"],
<RelayScoreBreakdown relay={relay.url} /> });
</Td>
<Td> const events = useSubject(timeline.timeline);
<RelayStatus url={relay.url} />
</Td> return (
<Td isNumeric> <Modal {...props}>
{pendingAdd.includes(relay) && ( <ModalOverlay />
<Badge colorScheme="green" mr="2"> <ModalContent>
Add <ModalHeader p="4" pb="0">
</Badge> {relay} reviews
)} </ModalHeader>
{pendingRemove.includes(relay) && ( <ModalCloseButton />
<Badge colorScheme="red" mr="2"> <ModalBody px="4" pt="0" pb="4">
Remove <Flex gap="2" direction="column">
</Badge> {events.map((event) => (
)} <RelayReviewNote key={event.id} event={event} />
<IconButton
icon={pendingRemove.includes(relay) ? <UndoIcon /> : <TrashIcon />}
title="Toggle Remove"
aria-label="Toggle Remove"
size="sm"
onClick={() => handleRemoveRelay(relay)}
isDisabled={saving}
/>
</Td>
</Tr>
))} ))}
</Tbody> </Flex>
</Table> </ModalBody>
</TableContainer> </ModalContent>
</Modal>
);
}
<form onSubmit={handleAddRelay}> function RelayCard({ url }: { url: string }) {
<FormControl> const account = useCurrentAccount();
<FormLabel htmlFor="relay-url-input">Add Relay</FormLabel> const { info } = useRelayInfo(url);
<Flex gap="2"> const clientRelays = useClientRelays();
<RelayUrlInput const reviewsModal = useDisclosure();
id="relay-url-input"
value={relayInputValue}
onChange={(url) => setRelayInputValue(url)}
isRequired
/>
<Button type="submit" isDisabled={saving}>
Add
</Button>
</Flex>
</FormControl>
</form>
<Flex justifyContent="flex-end" gap="2"> const joined = clientRelays.some((r) => r.url === url);
<Button type="submit" isLoading={saving} onClick={savePending} isDisabled={!hasPending}>
Save Changes 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>
</Flex> ) : (
</Flex> <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() { 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 ( return (
<RequireCurrentAccount> <Flex direction="column" gap="2" p="2">
<RelaysPage /> <Flex alignItems="center" gap="2">
</RequireCurrentAccount> <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>
); );
} }