mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-27 20:17:05 +02:00
rebuild relay view
This commit is contained in:
5
.changeset/violet-houses-switch.md
Normal file
5
.changeset/violet-houses-switch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Rebuild relay view and show relay reviews
|
@@ -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"));
|
||||
|
@@ -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 />
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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} />;
|
||||
});
|
||||
|
@@ -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>
|
||||
|
20
src/components/star-rating.tsx
Normal file
20
src/components/star-rating.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -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, "/");
|
||||
|
@@ -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() {
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -15,6 +15,8 @@ export type NostrQuery = {
|
||||
"#p"?: string[];
|
||||
"#d"?: string[];
|
||||
"#t"?: string[];
|
||||
"#r"?: string[];
|
||||
"#l"?: string[];
|
||||
"#g"?: string[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user