diff --git a/src/app.tsx b/src/app.tsx index 9e1191c24..748260721 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -57,7 +57,9 @@ const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => { const RootPage = () => ( - + }> + + ); diff --git a/src/components/connected-relays.tsx b/src/components/connected-relays.tsx index 0f3fcc52b..306847c09 100644 --- a/src/components/connected-relays.tsx +++ b/src/components/connected-relays.tsx @@ -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(relayPoolService.getRelays()); + const sortedRelays = useMemo(() => relayScoreboardService.getRankedRelays(relays.map((r) => r.url)), [relays]); useInterval(() => { setRelays(relayPoolService.getRelays()); @@ -41,17 +52,43 @@ export const ConnectedRelays = () => { )} - + Connected Relays - - {relays.map((relay) => ( - - {relay.url} - - ))} + + + + + + + + + + + + + + {sortedRelays.map((url) => ( + + + + + + + + ))} + +
RelayClaimsAvg ResponseDisconnectsStatus
+ + + {url} + + {relayPoolService.getRelayClaims(url).size}{relayScoreboardService.getAverageResponseTime(url).toFixed(2)}ms{relayScoreboardService.getDisconnects(url)} + +
+
diff --git a/src/helpers/relay.ts b/src/helpers/relay.ts new file mode 100644 index 000000000..407b9d1b0 --- /dev/null +++ b/src/helpers/relay.ts @@ -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[]); +} diff --git a/src/helpers/url.ts b/src/helpers/url.ts index 3d23e1a7c..4665fcb85 100644 --- a/src/helpers/url.ts +++ b/src/helpers/url.ts @@ -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; } diff --git a/src/hooks/use-merged-user-relays.tsx b/src/hooks/use-merged-user-relays.tsx new file mode 100644 index 000000000..209b8b8bc --- /dev/null +++ b/src/hooks/use-merged-user-relays.tsx @@ -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]); +} diff --git a/src/hooks/use-user-contacts.ts b/src/hooks/use-user-contacts.ts index 4a54c663b..00c7551ed 100644 --- a/src/hooks/use-user-contacts.ts +++ b/src/hooks/use-user-contacts.ts @@ -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); diff --git a/src/hooks/use-user-relays.ts b/src/hooks/use-user-relays.ts index 61a2b3e7f..3bcf7c1d3 100644 --- a/src/hooks/use-user-relays.ts +++ b/src/hooks/use-user-relays.ts @@ -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; } diff --git a/src/index.tsx b/src/index.tsx index ed2d35a17..ec8db3a02 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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); diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 486c39a54..4f7e84704 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -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(); } diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 41885530c..819a0b849 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -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; updated: number }; - indexes: { pubkey: string }; - }; relayScoreboardStats: { key: string; value: { relay: string; responseTimes: [number, Date][]; disconnects: Date[] }; diff --git a/src/services/pubkey-relay-weights.ts b/src/services/pubkey-relay-weights.ts deleted file mode 100644 index 5966c0f34..000000000 --- a/src/services/pubkey-relay-weights.ts +++ /dev/null @@ -1,70 +0,0 @@ -import moment from "moment"; -import db from "./db"; -import { UserContacts } from "./user-contacts"; - -const changed = new Set(); -const cache: Record | 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; diff --git a/src/services/relay-pool.ts b/src/services/relay-pool.ts index 0d33bc451..9de6e0ce8 100644 --- a/src/services/relay-pool.ts +++ b/src/services/relay-pool.ts @@ -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; diff --git a/src/services/relay-scoreboard.ts b/src/services/relay-scoreboard.ts index be4089852..48d20981c 100644 --- a/src/services/relay-scoreboard.ts +++ b/src/services/relay-scoreboard.ts @@ -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; } diff --git a/src/services/user-contacts.ts b/src/services/user-contacts.ts index 51c8c0090..0090abbea 100644 --- a/src/services/user-contacts.ts +++ b/src/services/user-contacts.ts @@ -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; + relays: RelayConfig[]; contacts: string[]; contactRelay: Record; created_at: number; }; +type RelayJson = Record; +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]) { diff --git a/src/services/user-followers.ts b/src/services/user-followers.ts index 68319ac42..240f36eb7 100644 --- a/src/services/user-followers.ts +++ b/src/services/user-followers.ts @@ -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); }); diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 175af1bc0..7027170a6 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -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) => { 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 }); } diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 8be48809f..d94a8177b 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -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 = () => { {tabs.map(({ label }) => ( - + }> + + ))} diff --git a/src/views/user/notes.tsx b/src/views/user/notes.tsx index 3f1656686..103c7ddc6 100644 --- a/src/views/user/notes.tsx +++ b/src/views/user/notes.tsx @@ -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 + + + + + + + + + + + {relays.map((url) => ( + {url} + ))} + + + + {timeline.map((event) => ( diff --git a/src/views/user/relays.tsx b/src/views/user/relays.tsx index db9a173c9..09f05ce19 100644 --- a/src/views/user/relays.tsx +++ b/src/views/user/relays.tsx @@ -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 ( - {!userRelays && contacts?.relays && ( - - - Cant find new relay list - - )} - {relays.map((relayConfig) => ( + {userRelays.map((relayConfig) => ( {relayConfig.url} {relayScoreboardService.getAverageResponseTime(relayConfig.url).toFixed(2)}ms