use users relays to fetch users notes

cleanup relay urls
This commit is contained in:
hzrd149 2023-03-05 17:58:24 -06:00
parent effd816707
commit ce3e61b48a
19 changed files with 187 additions and 144 deletions

View File

@ -57,7 +57,9 @@ const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
const RootPage = () => (
<RequireCurrentAccount>
<Page>
<Outlet />
<Suspense fallback={<Spinner />}>
<Outlet />
</Suspense>
</Page>
</RequireCurrentAccount>
);

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Text,
useDisclosure,
@ -9,6 +9,14 @@ import {
ModalBody,
ModalCloseButton,
Button,
TableContainer,
Table,
Thead,
Tbody,
Td,
Tr,
Th,
Flex,
} from "@chakra-ui/react";
import relayPoolService from "../services/relay-pool";
import { useInterval } from "react-use";
@ -16,11 +24,14 @@ import { RelayStatus } from "./relay-status";
import { useIsMobile } from "../hooks/use-is-mobile";
import { RelayIcon } from "./icons";
import { Relay } from "../classes/relay";
import { RelayFavicon } from "./relay-favicon";
import relayScoreboardService from "../services/relay-scoreboard";
export const ConnectedRelays = () => {
const isMobile = useIsMobile();
const { isOpen, onOpen, onClose } = useDisclosure();
const [relays, setRelays] = useState<Relay[]>(relayPoolService.getRelays());
const sortedRelays = useMemo(() => relayScoreboardService.getRankedRelays(relays.map((r) => r.url)), [relays]);
useInterval(() => {
setRelays(relayPoolService.getRelays());
@ -41,17 +52,43 @@ export const ConnectedRelays = () => {
</span>
)}
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<Modal isOpen={isOpen} onClose={onClose} size="5xl">
<ModalOverlay />
<ModalContent>
<ModalHeader pb="0">Connected Relays</ModalHeader>
<ModalCloseButton />
<ModalBody>
{relays.map((relay) => (
<Text key={relay.url}>
<RelayStatus url={relay.url} /> {relay.url}
</Text>
))}
<ModalBody p="2">
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Relay</Th>
<Th isNumeric>Claims</Th>
<Th isNumeric>Avg Response</Th>
<Th isNumeric>Disconnects</Th>
<Th isNumeric>Status</Th>
</Tr>
</Thead>
<Tbody>
{sortedRelays.map((url) => (
<Tr key={url}>
<Td>
<Flex alignItems="center" maxW="sm" overflow="hidden">
<RelayFavicon size="xs" relay={url} mr="2" />
<Text>{url}</Text>
</Flex>
</Td>
<Td isNumeric>{relayPoolService.getRelayClaims(url).size}</Td>
<Td isNumeric>{relayScoreboardService.getAverageResponseTime(url).toFixed(2)}ms</Td>
<Td isNumeric>{relayScoreboardService.getDisconnects(url)}</Td>
<Td isNumeric>
<RelayStatus url={url} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</ModalBody>
</ModalContent>
</Modal>

12
src/helpers/relay.ts Normal file
View File

@ -0,0 +1,12 @@
import { RelayConfig } from "../classes/relay";
import { safeRelayUrl } from "./url";
export function normalizeRelayConfigs(relays: RelayConfig[]) {
return relays.reduce((newArr, r) => {
const safeUrl = safeRelayUrl(r.url);
if (safeUrl) {
newArr.push({ ...r, url: safeUrl });
}
return newArr;
}, [] as RelayConfig[]);
}

View File

@ -1,17 +1,20 @@
import { utils } from "nostr-tools";
export function validateRelayUrl(relayUrl: string) {
const normalized = utils.normalizeURL(relayUrl);
const url = new URL(normalized);
export function normalizeRelayUrl(relayUrl: string) {
const url = new URL(relayUrl);
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
return url.toString();
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 validateRelayUrl(relayUrl);
return normalizeRelayUrl(relayUrl);
} catch (e) {}
return null;
}

View File

@ -0,0 +1,26 @@
import { useMemo } from "react";
import { RelayConfig, RelayMode } from "../classes/relay";
import { normalizeRelayConfigs } from "../helpers/relay";
import relayScoreboardService from "../services/relay-scoreboard";
import { useUserContacts } from "./use-user-contacts";
import { useUserRelays } from "./use-user-relays";
export default function useMergedUserRelays(pubkey: string) {
const contacts = useUserContacts(pubkey);
const userRelays = useUserRelays(pubkey);
return useMemo(() => {
let relays: RelayConfig[] = userRelays?.relays ?? [];
// use the relays stored in contacts if there are no relay config
if (relays.length === 0 && contacts) {
relays = contacts.relays;
}
// normalize relay urls and remove bad ones
const normalized = normalizeRelayConfigs(relays);
const rankedUrls = relayScoreboardService.getRankedRelays(normalized.map((r) => r.url));
return rankedUrls.map((u) => normalized.find((r) => r.url === u) as RelayConfig);
}, [userRelays, contacts]);
}

View File

@ -5,12 +5,11 @@ import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
export function useUserContacts(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
const clientRelays = useReadRelayUrls();
const relays = useMemo(() => unique(clientRelays.concat(additionalRelays)), [additionalRelays.join(",")]);
const readRelays = useReadRelayUrls(additionalRelays);
const observable = useMemo(
() => userContactsService.requestContacts(pubkey, relays, alwaysRequest),
[pubkey, relays, alwaysRequest]
() => userContactsService.requestContacts(pubkey, readRelays, alwaysRequest),
[pubkey, readRelays, alwaysRequest]
);
const contacts = useSubject(observable);

View File

@ -1,18 +1,16 @@
import { useMemo } from "react";
import { unique } from "../helpers/array";
import userRelaysService from "../services/user-relays";
import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
export function useUserRelays(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
const clientRelays = useReadRelayUrls();
const relays = useMemo(() => unique(clientRelays.concat(additionalRelays)), [additionalRelays.join(",")]);
const readRelays = useReadRelayUrls(additionalRelays);
const observable = useMemo(
() => userRelaysService.requestRelays(pubkey, relays, alwaysRequest),
[pubkey, relays, alwaysRequest]
() => userRelaysService.requestRelays(pubkey, readRelays, alwaysRequest),
[pubkey, readRelays.join("|"), alwaysRequest]
);
const contacts = useSubject(observable);
const userRelays = useSubject(observable);
return contacts;
return userRelays;
}

View File

@ -3,8 +3,6 @@ import { createRoot } from "react-dom/client";
import { App } from "./app";
import { Providers } from "./providers";
import "./services/pubkey-relay-weights";
const element = document.getElementById("root");
if (!element) throw new Error("missing mount point");
const root = createRoot(element);

View File

@ -38,8 +38,6 @@ const MIGRATIONS: MigrationFunction[] = [
dnsIdentifiers.createIndex("domain", "domain", { unique: false });
dnsIdentifiers.createIndex("updated", "updated", { unique: false });
db.createObjectStore("pubkeyRelayWeights", { keyPath: "pubkey" });
db.createObjectStore("settings");
db.createObjectStore("relayInfo");
db.createObjectStore("relayScoreboardStats", { keyPath: "relay" });
@ -69,7 +67,7 @@ export async function clearCacheData() {
await db.clear("userRelays");
await db.clear("relayInfo");
await db.clear("dnsIdentifiers");
await db.clear("pubkeyRelayWeights");
await db.clear("relayScoreboardStats");
window.location.reload();
}

View File

@ -30,11 +30,6 @@ export interface CustomSchema extends DBSchema {
indexes: { name: string; domain: string; pubkey: string; updated: number };
};
relayInfo: { key: string; value: RelayInformationDocument };
pubkeyRelayWeights: {
key: string;
value: { pubkey: string; relays: Record<string, number>; updated: number };
indexes: { pubkey: string };
};
relayScoreboardStats: {
key: string;
value: { relay: string; responseTimes: [number, Date][]; disconnects: Date[] };

View File

@ -1,70 +0,0 @@
import moment from "moment";
import db from "./db";
import { UserContacts } from "./user-contacts";
const changed = new Set();
const cache: Record<string, Record<string, number> | undefined> = {};
async function populateCacheFromDb(pubkey: string) {
if (!cache[pubkey]) {
cache[pubkey] = (await db.get("pubkeyRelayWeights", pubkey))?.relays;
}
}
async function addWeight(pubkey: string, relay: string, weight: number = 1) {
await populateCacheFromDb(pubkey);
const relays = cache[pubkey] || (cache[pubkey] = {});
if (relays[relay]) {
relays[relay] += weight;
} else {
relays[relay] = weight;
}
changed.add(pubkey);
}
async function saveCache() {
const now = moment().unix();
const transaction = db.transaction("pubkeyRelayWeights", "readwrite");
for (const [pubkey, relays] of Object.entries(cache)) {
if (changed.has(pubkey)) {
if (relays) transaction.store?.put({ pubkey, relays, updated: now });
}
}
changed.clear();
}
async function handleContactList(contacts: UserContacts) {
// save the relays for contacts
for (const [pubkey, relay] of Object.entries(contacts.contactRelay)) {
if (relay) await addWeight(pubkey, relay);
}
// save this pubkeys relays
for (const [relay, opts] of Object.entries(contacts.relays)) {
// only save relays this users writes to
if (opts.write) {
await addWeight(contacts.pubkey, relay);
}
}
}
async function getPubkeyRelays(pubkey: string) {
await populateCacheFromDb(pubkey);
return cache[pubkey] || {};
}
const pubkeyRelayWeightsService = {
handleContactList,
getPubkeyRelays,
};
setInterval(() => saveCache(), 1000);
if (import.meta.env.DEV) {
// @ts-ignore
window.pubkeyRelayWeightsService = pubkeyRelayWeightsService;
}
export default pubkeyRelayWeightsService;

View File

@ -66,6 +66,11 @@ export class RelayPoolService {
const relayPoolService = new RelayPoolService();
setInterval(() => {
relayPoolService.reconnectRelays();
relayPoolService.pruneRelays();
}, 1000 * 15);
if (import.meta.env.DEV) {
// @ts-ignore
window.relayPoolService = relayPoolService;

View File

@ -47,8 +47,7 @@ class RelayScoreboardService {
getAverageResponseTime(relay: string) {
const times = this.relayResponseTimes.get(relay);
// TODO: not sure if this is a good fix for keeping unconnected relays from the top of the list
if (times.length === 0) return 200;
if (times.length === 0) return Infinity;
const total = times.reduce((total, [time]) => total + time, 0);
return total / times.length;
}

View File

@ -4,17 +4,31 @@ import db from "./db";
import { CachedPubkeyEventRequester } from "../classes/cached-pubkey-event-requester";
import { SuperMap } from "../classes/super-map";
import Subject from "../classes/subject";
import { RelayConfig, RelayMode } from "../classes/relay";
export type UserContacts = {
pubkey: string;
relays: Record<string, { read: boolean; write: boolean }>;
relays: RelayConfig[];
contacts: string[];
contactRelay: Record<string, string | undefined>;
created_at: number;
};
type RelayJson = Record<string, { read: boolean; write: boolean }>;
function relayJsonToRelayConfig(relayJson: RelayJson) {
try {
return Array.from(Object.entries(relayJson)).map(([url, opts]) => ({
url,
mode: (opts.write ? RelayMode.WRITE : 0) | (opts.read ? RelayMode.READ : 0),
}));
} catch (e) {}
return [];
}
function parseContacts(event: NostrEvent): UserContacts {
const relays = safeJson(event.content, {}) as UserContacts["relays"];
const relayJson = safeJson(event.content, {}) as RelayJson;
const relays = relayJsonToRelayConfig(relayJson);
const pubkeys = event.tags.filter(isPTag).map((tag) => tag[1]);
const contactRelay = event.tags.filter(isPTag).reduce((dir, tag) => {
if (tag[2]) {

View File

@ -83,7 +83,7 @@ function receiveEvent(event: NostrEvent) {
}
subscription.onEvent.subscribe((event) => {
// pass the event ot the contacts service
// pass the event to the contacts service
userContactsService.receiveEvent(event);
receiveEvent(event);
});

View File

@ -25,7 +25,7 @@ import { RelayUrlInput } from "../../components/relay-url-input";
import useSubject from "../../hooks/use-subject";
import { RelayStatus } from "../../components/relay-status";
import relayScoreboardService from "../../services/relay-scoreboard";
import { validateRelayUrl } from "../../helpers/url";
import { normalizeRelayUrl } from "../../helpers/url";
export default function RelaysView() {
const relays = useSubject(clientRelaysService.relays);
@ -54,7 +54,7 @@ export default function RelaysView() {
const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const url = validateRelayUrl(relayInputValue);
const url = normalizeRelayUrl(relayInputValue);
if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) {
addActions.push({ url, mode: RelayMode.ALL });
}

View File

@ -1,4 +1,4 @@
import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { Flex, Spinner, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { Outlet, useLoaderData, useMatches, useNavigate } from "react-router-dom";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
@ -6,6 +6,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { useAppTitle } from "../../hooks/use-app-title";
import Header from "./components/header";
import { Suspense } from "react";
const tabs = [
{ label: "Notes", path: "notes" },
@ -52,7 +53,9 @@ const UserView = () => {
<TabPanels overflow={isMobile ? undefined : "auto"} height="100%">
{tabs.map(({ label }) => (
<TabPanel key={label} pr={0} pl={0}>
<Outlet context={{ pubkey }} />
<Suspense fallback={<Spinner />}>
<Outlet context={{ pubkey }} />
</Suspense>
</TabPanel>
))}
</TabPanels>

View File

@ -1,14 +1,39 @@
import { Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
ListItem,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverTrigger,
Spinner,
Switch,
UnorderedList,
useDisclosure,
} from "@chakra-ui/react";
import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { RelayMode } from "../../classes/relay";
import { RelayIcon } from "../../components/icons";
import { Note } from "../../components/note";
import { isNote } from "../../helpers/nostr-event";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useMergedUserRelays from "../../hooks/use-merged-user-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useReadRelayUrls();
const userRelays = useMergedUserRelays(pubkey);
const relays = userRelays
.filter((r) => r.mode & RelayMode.WRITE)
.map((r) => r.url)
.filter(Boolean)
.slice(0, 4) as string[];
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { events, loading, loadMore } = useTimelineLoader(
@ -26,6 +51,25 @@ const UserNotesTab = () => {
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={toggleReplies} />
<Box flexGrow={1} />
<Popover>
<PopoverTrigger>
<Button variant="link" leftIcon={<RelayIcon />}>
Using Relays
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody>
<UnorderedList>
{relays.map((url) => (
<ListItem key={url}>{url}</ListItem>
))}
</UnorderedList>
</PopoverBody>
</PopoverContent>
</Popover>
</FormControl>
{timeline.map((event) => (
<Note key={event.id} event={event} maxHeight={300} />

View File

@ -1,38 +1,18 @@
import { Text, Grid, Box, IconButton, Flex, Heading, Badge, Alert, AlertIcon } from "@chakra-ui/react";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { Text, Box, IconButton, Flex, Badge } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom";
import { GlobalIcon } from "../../components/icons";
import { useUserRelays } from "../../hooks/use-user-relays";
import { useMemo } from "react";
import relayScoreboardService from "../../services/relay-scoreboard";
import { RelayConfig, RelayMode } from "../../classes/relay";
import { RelayMode } from "../../classes/relay";
import useMergedUserRelays from "../../hooks/use-merged-user-relays";
const UserRelaysTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const contacts = useUserContacts(pubkey);
const userRelays = useUserRelays(pubkey);
const userRelays = useMergedUserRelays(pubkey);
const navigate = useNavigate();
const relays = useMemo(() => {
let arr: RelayConfig[] = userRelays?.relays ?? [];
if (arr.length === 0 && contacts)
arr = Array.from(Object.entries(contacts.relays)).map(([url, opts]) => ({
url,
mode: (opts.write ? RelayMode.WRITE : 0) | (opts.read ? RelayMode.READ : 0),
}));
const rankedUrls = relayScoreboardService.getRankedRelays(arr.map((r) => r.url));
return rankedUrls.map((u) => arr.find((r) => r.url === u) as RelayConfig);
}, [userRelays, contacts]);
return (
<Flex direction="column" gap="2">
{!userRelays && contacts?.relays && (
<Alert status="warning">
<AlertIcon />
Cant find new relay list
</Alert>
)}
{relays.map((relayConfig) => (
{userRelays.map((relayConfig) => (
<Box key={relayConfig.url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{relayConfig.url}</Text>
<Text>{relayScoreboardService.getAverageResponseTime(relayConfig.url).toFixed(2)}ms</Text>