add user relay service

remove settings.relays
This commit is contained in:
hzrd149 2023-02-10 09:18:41 -06:00
parent a517df5386
commit 4413b3588c
43 changed files with 639 additions and 405 deletions

View File

@ -48,6 +48,7 @@
- [ ] [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md): Authentication of clients to relays
- [ ] [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md): Keywords filter
- [ ] [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md): Reporting
- [x] [NIP-65](https://github.com/nostr-protocol/nips/blob/master/65.md): Relay List Metadata
## TODO

View File

@ -1,7 +1,9 @@
{
"_": "Download from https://nostr.watch/",
"_": "Download from https://nostr.watch/relays.json",
"relays": [
"was://nostr.pleb.network",
"wss://astral.ninja",
"wss://bitcoiner.social",
"wss://blg.nostr.sx",
"wss://brb.io",
"wss://btc.klendazu.com",
@ -9,14 +11,19 @@
"wss://deschooling.us",
"wss://e.nos.lol",
"wss://eden.nostr.land",
"wss://eden.nostr.land",
"wss://expensive-relay.fiatjaf.com",
"wss://foolay.nostr.moe",
"wss://freedom-relay.herokuapp.com/ws",
"wss://freespeech.casa",
"wss://global.relay.red",
"wss://jiggytom.ddns.net",
"wss://knostr.neutrine.com:8880",
"wss://knostr.neutrine.com",
"wss://lightningrelay.com",
"wss://lv01.tater.ninja",
"wss://Merrcurr.up.railway.app",
"wss://middling.myddns.me:8080",
"wss://mule.platanito.org",
"wss://no-str.org",
"wss://no.contry.xyz",
@ -25,6 +32,7 @@
"wss://node01.nostress.cc",
"wss://nos.lol",
"wss://nostr-01.bolt.observer",
"wss://nostr-01.dorafactory.org",
"wss://nostr-1.nbo.angani.co",
"wss://nostr-2.orba.ca",
"wss://nostr-2.zebedee.cloud",
@ -46,6 +54,7 @@
"wss://nostr-relay.j3s7m4n.com",
"wss://nostr-relay.lnmarkets.com",
"wss://nostr-relay.nonce.academy",
"wss://nostr-relay.pcdkd.fyi",
"wss://nostr-relay.schnitzel.world",
"wss://nostr-relay.smoove.net",
"wss://nostr-relay.texashedge.xyz",
@ -53,28 +62,42 @@
"wss://nostr-relay.untethr.me",
"wss://nostr-relay.usebitcoin.space",
"wss://nostr-relay.wolfandcrow.tech",
"wss://nostr-sg.com",
"wss://nostr-sg.com",
"wss://nostr-verif.slothy.win",
"wss://nostr-verified.wellorder.net",
"wss://nostr.1f52b.xyz",
"wss://nostr.21crypto.ch",
"wss://nostr.21m.fr",
"wss://nostr.8e23.net",
"wss://nostr.actn.io",
"wss://nostr.ahaspharos.de",
"wss://nostr.aozing.com",
"wss://nostr.app.runonflux.io",
"wss://nostr.argdx.net",
"wss://nostr.arguflow.gg",
"wss://nostr.bch.ninja",
"wss://nostr.bingtech.tk",
"wss://nostr.bitcoin-21.org",
"wss://nostr.bitcoin-21.org",
"wss://nostr.bitcoin.sex",
"wss://nostr.bitcoiner.social",
"wss://nostr.bitcoinplebs.de",
"wss://nostr.blockchaincaffe.it",
"wss://nostr.blockpower.capital",
"wss://nostr.blocs.fr",
"wss://nostr.bongbong.com",
"wss://nostr.bostonbtc.com",
"wss://nostr.cercatrova.me",
"wss://nostr.chainofimmortals.net",
"wss://nostr.chaker.net",
"wss://nostr.cheeserobot.org",
"wss://nostr.cizmar.net",
"wss://nostr.coinos.io",
"wss://nostr.coinsamba.com.br",
"wss://nostr.com.de",
"wss://nostr.coollamer.com",
"wss://nostr.corebreach.com",
"wss://nostr.cro.social",
"wss://nostr.d11n.net",
"wss://nostr.datamagik.com",
"wss://nostr.delo.software",
@ -89,26 +112,33 @@
"wss://nostr.fly.dev",
"wss://nostr.fmt.wiz.biz",
"wss://nostr.formigator.eu",
"wss://nostr.fractalized.ovh",
"wss://nostr.globals.fans",
"wss://nostr.gromeul.eu",
"wss://nostr.gruntwerk.org",
"wss://nostr.hackerman.pro",
"wss://nostr.handyjunky.com",
"wss://nostr.hugo.md",
"wss://nostr.hyperlingo.com",
"wss://nostr.inosta.cc",
"wss://nostr.island.network",
"wss://nostr.itssilvestre.com",
"wss://nostr.jiashanlu.synology.me",
"wss://nostr.jimc.me",
"wss://nostr.klabo.blog",
"wss://nostr.kollider.xyz",
"wss://nostr.lapalomilla.mx",
"wss://nostr.leximaster.com",
"wss://nostr.lnorb.com",
"wss://nostr.lnprivate.network",
"wss://nostr.localhost.re",
"wss://nostr.lordkno.ws",
"wss://nostr.lu.ke",
"wss://nostr.mado.io",
"wss://nostr.massmux.com",
"wss://nostr.mikedilger.com",
"wss://nostr.milou.lol",
"wss://nostr.milou.lol",
"wss://nostr.mom",
"wss://nostr.mouton.dev",
"wss://nostr.mrbits.it",
@ -116,6 +146,7 @@
"wss://nostr.mwmdev.com",
"wss://nostr.namek.link",
"wss://nostr.ncsa.illinois.edu",
"wss://nostr.nikolaj.online",
"wss://nostr.nodeofsven.com",
"wss://nostr.noones.com",
"wss://nostr.nordlysln.net",
@ -124,12 +155,13 @@
"wss://nostr.onsats.org",
"wss://nostr.oooxxx.ml",
"wss://nostr.openchain.fr",
"wss://nostr.orangepill.dev",
"wss://nostr.orba.ca",
"wss://nostr.oxtr.dev",
"wss://nostr.p2sh.co",
"wss://nostr.pleb.network",
"wss://nostr.plebchain.org",
"wss://nostr.pobblelabs.org",
"wss://nostr.radixrat.com",
"wss://nostr.randomdevelopment.biz",
"wss://nostr.rdfriedl.com",
"wss://nostr.relayer.se",
"wss://nostr.rewardsbunny.com",
@ -137,52 +169,69 @@
"wss://nostr.rocks",
"wss://nostr.roundrockbitcoiners.com",
"wss://nostr.sandwich.farm",
"wss://nostr.satoshi.fun",
"wss://nostr.satsophone.tk",
"wss://nostr.screaminglife.io",
"wss://nostr.sebastix.dev",
"wss://nostr.sectiontwo.org",
"wss://nostr.semisol.dev",
"wss://nostr.sg",
"wss://nostr.shadownode.org",
"wss://nostr.shawnyeager.net",
"wss://nostr.shmueli.org",
"wss://nostr.sidnlabs.nl",
"wss://nostr.simatime.com",
"wss://nostr.slothy.win",
"wss://nostr.snblago.com",
"wss://nostr.sovbit.com",
"wss://nostr.supremestack.xyz",
"wss://nostr.swiss-enigma.ch",
"wss://nostr.thesimplekid.com",
"wss://nostr.thibautrey.fr",
"wss://nostr.tunnelsats.com",
"wss://nostr.unknown.place",
"wss://nostr.up.railway.app",
"wss://nostr.uselessshit.co",
"wss://nostr.utxo.lol",
"wss://nostr.v0l.io",
"wss://nostr.vulpem.com",
"wss://nostr.w3ird.tech",
"wss://nostr.walletofsatoshi.com",
"wss://nostr.whoop.ph",
"wss://nostr.wine",
"wss://nostr.wine",
"wss://nostr.xpersona.net",
"wss://nostr.yael.at",
"wss://nostr.zaprite.io",
"wss://nostr.zebedee.cloud",
"wss://nostr.zenon.wtf",
"wss://nostr.zerofeerouting.com",
"wss://nostr.zkid.social",
"wss://nostr.zoomout.chat",
"wss://nostr1.starbackr.me",
"wss://nostr1.tunnelsats.com",
"wss://nostr2.actn.io",
"wss://nostr2.namek.link",
"wss://nostr21.com",
"wss://nostr3.actn.io",
"wss://nostrafrica.pcdkd.fyi",
"wss://nostream.gromeul.eu",
"wss://nostream.kinchie.snowinning.com",
"wss://nostream.nostrly.io",
"wss://nostrex.fly.dev",
"wss://nostrical.com",
"wss://nostrich.friendship.tw",
"wss://nostrrelay.com",
"wss://offchain.pub",
"wss://paid.no.str.cr",
"wss://paid.spore.ws",
"wss://pow32.nostr.land",
"wss://private-nostr.v0l.io",
"wss://private.red.gb.net",
"wss://public.nostr.swissrouting.com",
"wss://relay-pub.deschooling.us",
"wss://relay.21spirits.io",
"wss://relay.bitid.nz",
"wss://relay.bleskop.com",
"wss://relay.boring.surf",
"wss://relay.cryptocculture.com",
"wss://relay.current.fyi",
@ -192,6 +241,8 @@
"wss://relay.farscapian.com",
"wss://relay.futohq.com",
"wss://relay.grunch.dev",
"wss://relay.honk.pw",
"wss://relay.koreus.social",
"wss://relay.kronkltd.net",
"wss://relay.lexingtonbitcoin.org",
"wss://relay.minds.com/nostr/v1/ws",
@ -199,28 +250,42 @@
"wss://relay.n057r.club",
"wss://relay.nosphr.com",
"wss://relay.nostr-latam.link",
"wss://relay.nostr.ae",
"wss://relay.nostr.africa",
"wss://relay.nostr.au",
"wss://relay.nostr.band",
"wss://relay.nostr.band/restricted",
"wss://relay.nostr.bg",
"wss://relay.nostr.ch",
"wss://relay.nostr.express",
"wss://relay.nostr.hu",
"wss://relay.nostr.info",
"wss://relay.nostr.lu",
"wss://relay.nostr.moe",
"wss://relay.nostr.nu",
"wss://relay.nostr.or.jp",
"wss://relay.nostr.pro",
"wss://relay.nostr.ro",
"wss://relay.nostr.scot",
"wss://relay.nostr.vision",
"wss://relay.nostr.wf",
"wss://relay.nostr.wirednet.jp",
"wss://relay.nostr.xyz",
"wss://relay.nostrgraph.net",
"wss://relay.nostrgraph.net",
"wss://relay.nostrich.de",
"wss://relay.nostrich.land",
"wss://relay.nostriches.org",
"wss://relay.nostrid.com",
"wss://relay.nostrmoto.xyz",
"wss://relay.nostrology.org",
"wss://relay.nostropolis.xyz/websocket",
"wss://relay.nostrprotocol.net",
"wss://relay.nostrview.com",
"wss://relay.nostrzoo.com",
"wss://relay.nyx.ma",
"wss://relay.oldcity-bitcoiners.info",
"wss://relay.orangepill.dev",
"wss://relay.plebstr.com",
"wss://relay.r3d.red",
"wss://relay.ryzizub.com",
@ -229,12 +294,16 @@
"wss://relay.sovereign-stack.org",
"wss://relay.stoner.com",
"wss://relay.taxi",
"wss://relay.thes.ai",
"wss://relay.valireum.net",
"wss://relay.zeh.app",
"wss://rsr.uyky.net:30443",
"wss://rsslay.nostr.moe",
"wss://rsslay.nostr.net",
"wss://satstacker.cloud",
"wss://sg.qemura.xyz",
"wss://spleenrider.herokuapp.com",
"wss://spore.ws",
"wss://student.chadpolytechnic.com",
"wss://tmp-relay.cesc.trade",
"wss://wizards.wormrobot.org",

View File

@ -22,6 +22,7 @@ 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";
const RequireSetup = ({ children }: { children: JSX.Element }) => {
let location = useLocation();
@ -85,6 +86,10 @@ const router = createBrowserRouter([
path: "settings",
element: <SettingsView />,
},
{
path: "relays",
element: <RelaysView />,
},
{
path: "notifications",
element: <NotificationsView />,

View File

@ -13,7 +13,7 @@ import {
import { Relay } from "../services/relays";
import relayPool from "../services/relays/relay-pool";
import { useInterval } from "react-use";
import { RelayStatus } from "../views/settings/relay-status";
import { RelayStatus } from "./relay-status";
import { useIsMobile } from "../hooks/use-is-mobile";
import { RelayIcon } from "./icons";

View File

@ -4,7 +4,7 @@ import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19";
import { getUserDisplayName } from "../helpers/user-metadata";
import useSubject from "../hooks/use-subject";
import { useUserMetadata } from "../hooks/use-user-metadata";
import followingService from "../services/following";
import clientFollowingService from "../services/client-following";
import identity from "../services/identity";
import { UserAvatar } from "./user-avatar";
@ -29,7 +29,7 @@ const FollowingListItem = ({ pubkey }: { pubkey: string }) => {
export const FollowingList = () => {
const pubkey = useSubject(identity.pubkey);
const following = useSubject(followingService.following);
const following = useSubject(clientFollowingService.following);
if (!following) return <SkeletonText />;

View File

@ -173,3 +173,9 @@ export const NotificationIcon = createIcon({
d: "M5 18h14v-6.969C19 7.148 15.866 4 12 4s-7 3.148-7 7.031V18zm7-16c4.97 0 9 4.043 9 9.031V20H3v-8.969C3 6.043 7.03 2 12 2zM9.5 21h5a2.5 2.5 0 1 1-5 0z",
defaultProps,
});
export const UndoIcon = createIcon({
displayName: "UndoIcon",
d: "M5.828 7l2.536 2.536L6.95 10.95 2 6l4.95-4.95 1.414 1.414L5.828 5H13a8 8 0 1 1 0 16H4v-2h9a6 6 0 1 0 0-12H5.828z",
defaultProps,
});

View File

@ -17,22 +17,24 @@ import { NostrRequest } from "../../classes/nostr-request";
import useSubject from "../../hooks/use-subject";
import { getEventRelays, handleEventFromRelay } from "../../services/event-relays";
import { relayPool } from "../../services/relays";
import settings from "../../services/settings";
import { NostrEvent } from "../../types/nostr-event";
import { RelayIcon, SearchIcon } from "../icons";
import { RelayFavicon } from "../relay-favicon";
import { useReadRelayUrls, useWriteRelayUrls } from "../../hooks/use-client-relays";
export type NoteRelaysProps = Omit<IconButtonProps, "icon" | "aria-label"> & {
event: NostrEvent;
};
export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
const relays = useSubject(getEventRelays(event.id));
const eventRelays = useSubject(getEventRelays(event.id));
const readRelays = useReadRelayUrls();
const writeRelays = useWriteRelayUrls();
const [querying, setQuerying] = useState(false);
const queryRelays = useCallback(() => {
setQuerying(true);
const request = new NostrRequest(settings.relays.value);
const request = new NostrRequest(readRelays);
request.start({ ids: [event.id] });
request.onEvent.subscribe({
complete() {
@ -43,7 +45,7 @@ export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
const [broadcasting, setBroadcasting] = useState(false);
const broadcast = useCallback(() => {
const missingRelays = settings.relays.value.filter((url) => !relays.includes(url));
const missingRelays = writeRelays.filter((url) => !eventRelays.includes(url));
if (missingRelays.length === 0) {
return;
}
@ -64,14 +66,14 @@ export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
}, []);
return (
<Popover>
<Popover isLazy>
<PopoverTrigger>
<IconButton title="Note Relays" icon={<RelayIcon />} size={props.size ?? "sm"} aria-label="Note Relays" />
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
{relays.map((url) => (
{eventRelays.map((url) => (
<Flex alignItems="center" key={url}>
<RelayFavicon relay={url} size="2xs" mr="2" />
<Text>{url}</Text>

View File

@ -1,7 +1,7 @@
import React from "react";
import { Avatar, Button, Container, Flex, Heading, IconButton, LinkOverlay, Text, VStack } from "@chakra-ui/react";
import { Link, useNavigate } from "react-router-dom";
import { FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, SettingsIcon } from "./icons";
import { FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon, SettingsIcon } from "./icons";
import { ErrorBoundary } from "./error-boundary";
import { ConnectedRelays } from "./connected-relays";
@ -90,6 +90,9 @@ const DesktopSideNav = () => {
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}>
Notifications
</Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
Relays
</Button>
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />}>
Settings
</Button>

View File

@ -14,8 +14,8 @@ import React, { useState } from "react";
import { useList } from "react-use";
import { nostrPostAction, PostResult } from "../../classes/nostr-post-action";
import { getReferences } from "../../helpers/nostr-event";
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useIsMobile } from "../../hooks/use-is-mobile";
import settings from "../../services/settings";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import { NoteLink } from "../note-link";
import { PostResults } from "./post-results";
@ -39,6 +39,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
const isMobile = useIsMobile();
const pad = isMobile ? "2" : "4";
const writeRelays = useWriteRelayUrls();
const [waiting, setWaiting] = useState(false);
const [signedEvent, setSignedEvent] = useState<NostrEvent | null>(null);
const [results, resultsActions] = useList<PostResult>();
@ -56,7 +57,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
setWaiting(false);
setSignedEvent(event);
const postResults = nostrPostAction(settings.relays.value, event);
const postResults = nostrPostAction(writeRelays, event);
postResults.subscribe({
next(result) {

View File

@ -1,6 +1,6 @@
import { Badge, useForceUpdate } from "@chakra-ui/react";
import { useInterval } from "react-use";
import { Relay, relayPool } from "../../services/relays";
import { Relay, relayPool } from "../services/relays";
const getStatusText = (relay: Relay) => {
if (relay.connecting) return "Connecting...";

View File

@ -1,5 +1,6 @@
import { Input, InputProps } from "@chakra-ui/react";
import { useAsync } from "react-use";
import { unique } from "../helpers/array";
export type RelayUrlInputProps = Omit<InputProps, "type">;
@ -7,7 +8,7 @@ export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => {
const { value: relaysJson, loading: loadingRelaysJson } = useAsync(async () =>
fetch("/relays.json").then((res) => res.json() as Promise<{ relays: string[] }>)
);
const relaySuggestions = relaysJson?.relays ?? [];
const relaySuggestions = relaysJson?.relays ? unique(relaysJson?.relays) : [];
return (
<>

View File

@ -1,26 +1,26 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { useReadonlyMode } from "../hooks/use-readonly-mode";
import useSubject from "../hooks/use-subject";
import followingService from "../services/following";
import clientFollowingService from "../services/client-following";
export const UserFollowButton = ({
pubkey,
...props
}: { pubkey: string } & Omit<ButtonProps, "onClick" | "isLoading" | "isDisabled">) => {
const readonly = useReadonlyMode();
const following = useSubject(followingService.following);
const savingDraft = useSubject(followingService.savingDraft);
const following = useSubject(clientFollowingService.following);
const savingDraft = useSubject(clientFollowingService.savingDraft);
const isFollowing = following.some((t) => t[1] === pubkey);
const toggleFollow = async () => {
if (isFollowing) {
followingService.removeContact(pubkey);
clientFollowingService.removeContact(pubkey);
} else {
followingService.addContact(pubkey);
clientFollowingService.addContact(pubkey);
}
await followingService.savePendingDraft();
await clientFollowingService.savePending();
};
return (

View File

@ -1,6 +1,7 @@
import moment from "moment";
import { getEventRelays } from "../services/event-relays";
import { DraftNostrEvent, isETag, isPTag, NostrEvent } from "../types/nostr-event";
import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag } from "../types/nostr-event";
import { RelayConfig, RelayMode } from "../services/relays/relay";
export function isReply(event: NostrEvent | DraftNostrEvent) {
return !!event.tags.find((tag) => isETag(tag) && tag[3] !== "mention");
@ -83,3 +84,14 @@ export function buildReply(event: NostrEvent): DraftNostrEvent {
created_at: moment().unix(),
};
}
export function parseRTag(tag: RTag): RelayConfig {
switch (tag[2]) {
case "write":
return { url: tag[1], mode: RelayMode.WRITE };
case "read":
return { url: tag[1], mode: RelayMode.READ };
default:
return { url: tag[1], mode: RelayMode.ALL };
}
}

View File

@ -0,0 +1,29 @@
import { unique } from "../helpers/array";
import clientRelaysService from "../services/client-relays";
import { RelayMode } from "../services/relays/relay";
import useSubject from "./use-subject";
export function useClientRelays(mode: RelayMode = RelayMode.READ) {
const relays = useSubject(clientRelaysService.relays);
return relays.filter((r) => r.mode & mode);
}
export function useReadRelayUrls(additional: string[] = []) {
const relays = useClientRelays(RelayMode.READ);
const urls = relays.map((r) => r.url);
if (additional) {
return unique([...urls, ...additional]);
}
return urls;
}
export function useWriteRelayUrls(additional: string[] = []) {
const relays = useClientRelays(RelayMode.WRITE);
const urls = relays.map((r) => r.url);
if (additional) {
return unique([...urls, ...additional]);
}
return urls;
}

View File

@ -1,17 +1,17 @@
import { useRef } from "react";
import { useDeepCompareEffect, useUnmount } from "react-use";
import { NostrSubscription } from "../classes/nostr-subscription";
import settings from "../services/settings";
import { NostrQuery } from "../types/nostr-query";
import useSubject from "./use-subject";
import { useReadRelayUrls } from "./use-client-relays";
type Options = {
name?: string;
enabled?: boolean;
};
/** @deprecated */
export function useSubscription(query: NostrQuery, opts?: Options) {
const relays = useSubject(settings.relays);
const relays = useReadRelayUrls();
const sub = useRef<NostrSubscription | null>(null);
sub.current = sub.current || new NostrSubscription(relays, undefined, opts?.name);

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import { useUnmount } from "react-use";
import { ThreadLoader } from "../classes/thread-loader";
import { linkEvents } from "../helpers/thread";
import settings from "../services/settings";
import { useReadRelayUrls } from "./use-client-relays";
import useSubject from "./use-subject";
type Options = {
@ -10,7 +10,7 @@ type Options = {
};
export function useThreadLoader(eventId: string, opts?: Options) {
const relays = useSubject(settings.relays);
const relays = useReadRelayUrls();
const ref = useRef<ThreadLoader | null>(null);
const loader = (ref.current = ref.current || new ThreadLoader(relays, eventId));

View File

@ -2,9 +2,9 @@ import { useMemo } from "react";
import userContactsService from "../services/user-contacts";
import useSubject from "./use-subject";
export function useUserContacts(pubkey: string, relays: string[] = [], alwaysRequest = false) {
export function useUserContacts(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
const observable = useMemo(
() => userContactsService.requestContacts(pubkey, relays, alwaysRequest),
() => userContactsService.requestContacts(pubkey, additionalRelays, alwaysRequest),
[pubkey, alwaysRequest]
);
const contacts = useSubject(observable) ?? undefined;

View File

@ -0,0 +1,13 @@
import { useMemo } from "react";
import userRelaysService from "../services/user-relays";
import useSubject from "./use-subject";
export function useUserRelays(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
const observable = useMemo(
() => userRelaysService.requestRelays(pubkey, additionalRelays, alwaysRequest),
[pubkey, alwaysRequest]
);
const contacts = useSubject(observable) ?? undefined;
return contacts;
}

View File

@ -2,14 +2,13 @@ import moment from "moment";
import { BehaviorSubject, lastValueFrom } from "rxjs";
import { nostrPostAction } from "../classes/nostr-post-action";
import { DraftNostrEvent, PTag } from "../types/nostr-event";
import clientRelaysService from "./client-relays";
import identity from "./identity";
import settings from "./settings";
import userContactsService, { UserContacts } from "./user-contacts";
import userContactsService from "./user-contacts";
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
const following = new BehaviorSubject<PTag[]>([]);
// const relays = new BehaviorSubject<RelayDirectory>({});
const pendingDraft = new BehaviorSubject<DraftNostrEvent | null>(null);
const savingDraft = new BehaviorSubject(false);
@ -18,7 +17,7 @@ identity.pubkey.subscribe((pubkey) => {
// clear the following list until a new one can be fetched
following.next([]);
sub = userContactsService.requestContacts(pubkey, settings.relays.value, true).subscribe((userContacts) => {
sub = userContactsService.requestContacts(pubkey, [], true).subscribe((userContacts) => {
if (!userContacts) return;
following.next(
@ -50,7 +49,7 @@ function getDraftEvent(): DraftNostrEvent {
};
}
async function savePendingDraft() {
async function savePending() {
const draft = pendingDraft.value;
if (!draft) return;
@ -58,7 +57,7 @@ async function savePendingDraft() {
savingDraft.next(true);
const event = await window.nostr.signEvent(draft);
const results = nostrPostAction(settings.relays.value, event);
const results = nostrPostAction(clientRelaysService.getWriteUrls(), event);
await lastValueFrom(results);
savingDraft.next(false);
@ -92,18 +91,18 @@ function removeContact(pubkey: string) {
}
}
const followingService = {
const clientFollowingService = {
following: following,
isFollowing,
savingDraft,
savePendingDraft,
savePending,
addContact,
removeContact,
};
if (import.meta.env.DEV) {
// @ts-ignore
window.followingService = followingService;
window.clientFollowingService = clientFollowingService;
}
export default followingService;
export default clientFollowingService;

View File

@ -0,0 +1,96 @@
import moment from "moment";
import { BehaviorSubject, lastValueFrom, Subscription } from "rxjs";
import { nostrPostAction } from "../classes/nostr-post-action";
import { unique } from "../helpers/array";
import { DraftNostrEvent, RTag } from "../types/nostr-event";
import identity from "./identity";
import { RelayConfig, RelayMode } from "./relays/relay";
import userRelaysService from "./user-relays";
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
class ClientRelayService {
bootstrapRelays = new Set<string>();
relays = new BehaviorSubject<RelayConfig[]>([]);
writeRelays = new BehaviorSubject<RelayConfig[]>([]);
readRelays = new BehaviorSubject<RelayConfig[]>([]);
constructor() {
let sub: Subscription;
identity.pubkey.subscribe((pubkey) => {
// clear the relay list until a new one can be fetched
this.relays.next([]);
if (sub) sub.unsubscribe();
sub = userRelaysService.requestRelays(pubkey, Array.from(this.bootstrapRelays), true).subscribe((userRelays) => {
if (!userRelays) return;
this.relays.next(userRelays.relays);
});
});
// add preset relays fromm nip07 extension to bootstrap list
identity.relays.subscribe((presetRelays) => {
for (const [url, opts] of Object.entries(presetRelays)) {
if (opts.read) {
clientRelaysService.bootstrapRelays.add(url);
}
}
});
this.relays.subscribe((relays) => this.writeRelays.next(relays.filter((r) => r.mode & RelayMode.WRITE)));
this.relays.subscribe((relays) => this.readRelays.next(relays.filter((r) => r.mode & RelayMode.READ)));
}
async postUpdatedRelays(newRelays: RelayConfig[]) {
const rTags: RTag[] = newRelays.map((r) => {
switch (r.mode) {
case RelayMode.READ:
return ["r", r.url, "read"];
case RelayMode.WRITE:
return ["r", r.url, "write"];
case RelayMode.ALL:
default:
return ["r", r.url];
}
});
const draft: DraftNostrEvent = {
kind: 10002,
tags: rTags,
content: "",
created_at: moment().unix(),
};
const newRelayUrls = newRelays.filter((r) => r.mode & RelayMode.WRITE).map((r) => r.url);
const oldRelayUrls = this.relays.value.filter((r) => r.mode & RelayMode.WRITE).map((r) => r.url);
const writeUrls = unique([...oldRelayUrls, ...newRelayUrls]);
if (window.nostr) {
const event = await window.nostr.signEvent(draft);
const results = nostrPostAction(writeUrls, event);
await lastValueFrom(results);
// pass new event to the user relay service
userRelaysService.receiveEvent(event);
}
}
getWriteUrls() {
return this.relays.value.filter((r) => r.mode & RelayMode.WRITE).map((r) => r.url);
}
getReadUrls() {
return this.relays.value.filter((r) => r.mode & RelayMode.READ).map((r) => r.url);
}
}
const clientRelaysService = new ClientRelayService();
if (import.meta.env.DEV) {
// @ts-ignore
window.clientRelaysService = clientRelaysService;
}
export default clientRelaysService;

View File

@ -12,9 +12,15 @@ type MigrationFunction = (
const MIGRATIONS: MigrationFunction[] = [
// 0 -> 1
function (db, transaction, event) {
const metadata = db.createObjectStore("userMetadata", {
const userMetadata = db.createObjectStore("userMetadata", {
keyPath: "pubkey",
});
userMetadata.createIndex("created_at", "created_at");
const userRelays = db.createObjectStore("userRelays", {
keyPath: "pubkey",
});
userRelays.createIndex("created_at", "created_at");
const contacts = db.createObjectStore("userContacts", {
keyPath: "pubkey",

View File

@ -1,10 +1,12 @@
import { DBSchema } from "idb";
import { NostrEvent } from "../../types/nostr-event";
import { RelayConfig } from "../relays/relay";
export interface CustomSchema extends DBSchema {
userMetadata: {
key: string;
value: NostrEvent;
indexes: { created_at: number };
};
userContacts: {
key: string;
@ -17,6 +19,11 @@ export interface CustomSchema extends DBSchema {
};
indexes: { created_at: number; contacts: string };
};
userRelays: {
key: string;
value: { pubkey: string; relays: {url: string, mode: number}[]; created_at: number };
indexes: { created_at: number };
};
dnsIdentifiers: {
key: string;
value: { name: string; domain: string; pubkey: string; relays: string[]; updated: number };

View File

@ -1,5 +1,4 @@
import { BehaviorSubject } from "rxjs";
import { unique } from "../helpers/array";
import settings from "./settings";
export type PresetRelays = Record<string, { read: boolean; write: boolean }>;
@ -15,7 +14,7 @@ class IdentityService {
setup = new BehaviorSubject(false);
pubkey = new BehaviorSubject("");
readonly = new BehaviorSubject(false);
// TODO: remove this when there is a service to manage user relays
// directory of relays provided by nip07 extension
relays = new BehaviorSubject<PresetRelays>({});
private useExtension: boolean = false;
private secKey: string | undefined = undefined;
@ -48,21 +47,22 @@ class IdentityService {
useExtension: true,
});
// disabled because I dont want to load the preset relays yet (ably dose not support changing them)
// const relays = await window.nostr.getRelays();
// if (Array.isArray(relays)) {
// this.relays.next(relays.reduce<PresetRelays>((d, r) => ({ ...d, [r]: { read: true, write: true } }), {}));
// } else this.relays.next(relays);
const relays = await window.nostr.getRelays();
if (Array.isArray(relays)) {
this.relays.next(relays.reduce<PresetRelays>((d, r) => ({ ...d, [r]: { read: true, write: true } }), {}));
} else {
this.relays.next(relays);
}
}
}
loginWithSecKey(secKey: string) {
// const pubkey =
// settings.identity.next({
// pubkey,
// useExtension: true,
// });
}
// loginWithSecKey(secKey: string) {
// const pubkey =
// settings.identity.next({
// pubkey,
// useExtension: true,
// });
// }
loginWithPubkey(pubkey: string) {
this.readonly.next(true);
@ -83,9 +83,4 @@ if (import.meta.env.DEV) {
window.identity = identity;
}
// TODO: create a service to manage user relays (download latest and handle conflicts)
identity.relays.subscribe((presetRelays) => {
settings.relays.next(unique([...settings.relays.value, ...Object.keys(presetRelays)]));
});
export default identity;

View File

@ -29,9 +29,6 @@ export class RelayPool {
}
return relay;
}
requestRelays(urls: string[], connect = true) {
return urls.map((url) => this.requestRelay(url, connect));
}
pruneRelays() {
for (const [url, relay] of this.relays.entries()) {

View File

@ -23,12 +23,13 @@ export type IncomingCommandResult = {
message?: string;
};
export enum Permission {
export enum RelayMode {
NONE = 0,
READ = 1,
WRITE = 2,
ALL = 1 | 2,
}
export type RelayConfig = { url: string; mode: RelayMode };
export class Relay {
url: string;
@ -39,13 +40,13 @@ export class Relay {
onEndOfStoredEvents = new Subject<IncomingEOSE>();
onCommandResult = new Subject<IncomingCommandResult>();
ws?: WebSocket;
permission: Permission = Permission.ALL;
mode: RelayMode = RelayMode.ALL;
private queue: NostrOutgoingMessage[] = [];
constructor(url: string, permission: Permission = Permission.ALL) {
constructor(url: string, mode: RelayMode = RelayMode.ALL) {
this.url = url;
this.permission = permission;
this.mode = mode;
}
open() {
@ -71,7 +72,7 @@ export class Relay {
this.ws.onmessage = this.handleMessage.bind(this);
}
send(json: NostrOutgoingMessage) {
if (this.permission & Permission.WRITE) {
if (this.mode & RelayMode.WRITE) {
if (this.connected) {
this.ws?.send(JSON.stringify(json));
} else this.queue.push(json);
@ -116,7 +117,7 @@ export class Relay {
// skip empty events
if (!event.data) return;
if (!(this.permission & Permission.READ)) return;
if (!(this.mode & RelayMode.READ)) return;
try {
const data: RawIncomingNostrEvent = JSON.parse(event.data);

View File

@ -3,7 +3,6 @@ import db from "./db";
import { SavedIdentity } from "./identity";
const settings = {
relays: new BehaviorSubject<string[]>([]),
identity: new BehaviorSubject<SavedIdentity | null>(null),
blurImages: new BehaviorSubject(true),
autoShowMedia: new BehaviorSubject(true),

View File

@ -7,6 +7,7 @@ import db from "./db";
import settings from "./settings";
import userFollowersService from "./user-followers";
import pubkeyRelayWeightsService from "./pubkey-relay-weights";
import clientRelaysService from "./client-relays";
const subscription = new NostrSubscription([], undefined, "user-contacts");
const subjects = new PubkeySubjectCache<UserContacts>();
@ -39,10 +40,10 @@ function parseContacts(event: NostrEvent): UserContacts {
};
}
function requestContacts(pubkey: string, relays: string[] = [], alwaysRequest = false) {
function requestContacts(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
let subject = subjects.getSubject(pubkey);
if (relays.length) subjects.addRelays(pubkey, relays);
if (additionalRelays.length) subjects.addRelays(pubkey, additionalRelays);
if (alwaysRequest) forceRequestedKeys.add(pubkey);
@ -59,20 +60,20 @@ function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relays = new Set<string>();
const relayUrls = new Set<string>();
const pending = subjects.getAllPubkeysMissingData(Array.from(forceRequestedKeys));
for (const key of pending.pubkeys) pubkeys.add(key);
for (const url of pending.relays) relays.add(url);
for (const url of pending.relays) relayUrls.add(url);
if (pubkeys.size === 0) return;
const systemRelays = settings.relays.getValue();
for (const url of systemRelays) relays.add(url);
const clientRelays = clientRelaysService.getReadUrls();
for (const url of clientRelays) relayUrls.add(url);
const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [3] };
subscription.setRelays(Array.from(relays));
subscription.setRelays(Array.from(relayUrls));
subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();

View File

@ -3,10 +3,10 @@ import { NostrQuery } from "../types/nostr-query";
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
import { NostrSubscription } from "../classes/nostr-subscription";
import db from "./db";
import settings from "./settings";
import { BehaviorSubject } from "rxjs";
import { getReferences } from "../helpers/nostr-event";
import userContactsService from "./user-contacts";
import clientRelaysService from "./client-relays";
const subscription = new NostrSubscription([], undefined, "user-followers");
const subjects = new PubkeySubjectCache<string[]>();
@ -23,10 +23,10 @@ function mergeNext(subject: BehaviorSubject<string[] | null>, next: string[]) {
subject.next(arr);
}
function requestFollowers(pubkey: string, relays: string[] = [], alwaysRequest = false) {
function requestFollowers(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
let subject = subjects.getSubject(pubkey);
if (relays.length) subjects.addRelays(pubkey, relays);
if (additionalRelays.length) subjects.addRelays(pubkey, additionalRelays);
db.getAllKeysFromIndex("userContacts", "contacts", pubkey).then((cached) => {
mergeNext(subject, cached);
@ -41,20 +41,20 @@ function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relays = new Set<string>();
const relayUrls = new Set<string>();
const pending = subjects.getAllPubkeysMissingData(Array.from(forceRequestedKeys));
for (const key of pending.pubkeys) pubkeys.add(key);
for (const url of pending.relays) relays.add(url);
for (const url of pending.relays) relayUrls.add(url);
if (pubkeys.size === 0) return;
const systemRelays = settings.relays.getValue();
for (const url of systemRelays) relays.add(url);
const clientRelays = clientRelaysService.getReadUrls();
for (const url of clientRelays) relayUrls.add(url);
const query: NostrQuery = { kinds: [3], "#p": Array.from(pubkeys) };
subscription.setRelays(Array.from(relays));
subscription.setRelays(Array.from(relayUrls));
subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();

View File

@ -1,9 +1,9 @@
import db from "./db";
import settings from "./settings";
import { NostrSubscription } from "../classes/nostr-subscription";
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
import { Kind0ParsedContent, NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import clientRelaysService from "./client-relays";
type Metadata = Kind0ParsedContent & { created_at: number };
@ -11,10 +11,10 @@ const subscription = new NostrSubscription([], undefined, "user-metadata");
const subjects = new PubkeySubjectCache<Metadata>();
const forceRequestedKeys = new Set<string>();
function requestMetadata(pubkey: string, relays: string[], alwaysRequest = false) {
function requestMetadata(pubkey: string, additionalRelays: string[], alwaysRequest = false) {
let subject = subjects.getSubject(pubkey);
if (relays.length) subjects.addRelays(pubkey, relays);
if (additionalRelays.length) subjects.addRelays(pubkey, additionalRelays);
if (alwaysRequest) {
forceRequestedKeys.add(pubkey);
@ -42,20 +42,20 @@ function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relays = new Set<string>();
const relayUrls = new Set<string>();
const pending = subjects.getAllPubkeysMissingData(Array.from(forceRequestedKeys));
for (const key of pending.pubkeys) pubkeys.add(key);
for (const url of pending.relays) relays.add(url);
for (const url of pending.relays) relayUrls.add(url);
if (pubkeys.size === 0) return;
const systemRelays = settings.relays.getValue();
for (const url of systemRelays) relays.add(url);
const clientRelays = clientRelaysService.getReadUrls();
for (const url of clientRelays) relayUrls.add(url);
const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [0] };
subscription.setRelays(Array.from(relays));
subscription.setRelays(Array.from(relayUrls));
subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();

View File

@ -0,0 +1,94 @@
import db from "./db";
import { NostrSubscription } from "../classes/nostr-subscription";
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
import { isRTag, NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { RelayConfig } from "./relays/relay";
import { parseRTag } from "../helpers/nostr-event";
import clientRelaysService from "./client-relays";
export type UserRelays = {
pubkey: string;
relays: RelayConfig[];
created_at: number;
};
const subscription = new NostrSubscription([], undefined, "user-relays");
const subjects = new PubkeySubjectCache<UserRelays>();
const forceRequestedKeys = new Set<string>();
function requestRelays(pubkey: string, relays: string[], alwaysRequest = false) {
let subject = subjects.getSubject(pubkey);
if (relays.length) subjects.addRelays(pubkey, relays);
if (alwaysRequest) forceRequestedKeys.add(pubkey);
if (!subject.value) {
db.get("userRelays", pubkey).then((cached) => {
if (cached) {
subject.next(cached);
}
});
}
return subject;
}
function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relayUrls = new Set<string>();
const pending = subjects.getAllPubkeysMissingData(Array.from(forceRequestedKeys));
for (const key of pending.pubkeys) pubkeys.add(key);
for (const url of pending.relays) relayUrls.add(url);
if (pubkeys.size === 0) return;
const clientRelays = clientRelaysService.readRelays.value;
for (const relay of clientRelays) relayUrls.add(relay.url);
const query: NostrQuery = { authors: Array.from(pubkeys), kinds: [10002] };
subscription.setRelays(Array.from(relayUrls));
subscription.setQuery(query);
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();
}
subjects.dirty = false;
}
function receiveEvent(event: NostrEvent) {
const subject = subjects.getSubject(event.pubkey);
const latest = subject.getValue();
if (!latest || event.created_at > latest.created_at) {
const userRelays = {
pubkey: event.pubkey,
relays: event.tags.filter(isRTag).map(parseRTag),
created_at: event.created_at,
};
subject.next(userRelays);
db.put("userRelays", userRelays);
forceRequestedKeys.delete(event.pubkey);
}
}
subscription.onEvent.subscribe(receiveEvent);
// flush requests every second
setInterval(() => {
subjects.prune();
flushRequests();
}, 1000 * 2);
const userRelaysService = { requestRelays, flushRequests, subjects, receiveEvent };
if (import.meta.env.DEV) {
// @ts-ignore
window.userRelaysService = userRelaysService;
}
export default userRelaysService;

View File

@ -1,12 +1,14 @@
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
export type PTag = ["p", string] | ["p", string, string];
export type RTag = ["r", string] | ["r", string, string];
export type Tag =
| [string]
| [string, string]
| [string, string, string]
| [string, string, string, string]
| ETag
| PTag;
| PTag
| RTag;
export type NostrEvent = {
id: string;
@ -44,3 +46,6 @@ export function isETag(tag: Tag): tag is ETag {
export function isPTag(tag: Tag): tag is PTag {
return tag[0] === "p" && tag[1] !== undefined;
}
export function isRTag(tag: Tag): tag is RTag {
return tag[0] === "r" && tag[1] !== undefined;
}

View File

@ -1,145 +0,0 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Flex,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
Switch,
} from "@chakra-ui/react";
import { useState } from "react";
import { useList } from "react-use";
import { RelayUrlInput } from "../../../components/relay-url-input";
import { unique } from "../../../helpers/array";
import useSubject from "../../../hooks/use-subject";
import settings from "../../../services/settings";
const CustomRelayForm = ({ onSubmit }: { onSubmit: (url: string) => void }) => {
const [customRelay, setCustomRelay] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(customRelay);
setCustomRelay("");
}}
>
<Flex gap="2">
<RelayUrlInput
size="sm"
placeholder="wss://relay.example.com"
value={customRelay}
onChange={(e) => setCustomRelay(e.target.value)}
/>
<Button size="sm" type="submit">
Add
</Button>
</Flex>
</form>
);
};
export type FilterValues = {
relays: string[];
};
export type FeedFiltersProps = {
isOpen: boolean;
onClose: ModalProps["onClose"];
values: FilterValues;
onSave: (values: FilterValues) => void;
};
export const FeedFilters = ({ isOpen, onClose, values }: FeedFiltersProps) => {
const defaultRelays = useSubject(settings.relays);
const [selectedRelays, relayActions] = useList(values.relays);
const availableRelays = unique([...defaultRelays, ...selectedRelays]);
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Filters</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Relays
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
{availableRelays.map((url) => (
<Box key={url}>
<FormLabel>
<Switch
size="sm"
mr="2"
isChecked={selectedRelays.includes(url)}
onChange={() =>
selectedRelays.includes(url)
? relayActions.removeAt(selectedRelays.indexOf(url))
: relayActions.push(url)
}
/>
{url}
</FormLabel>
</Box>
))}
<Flex gap="2">
<Button size="sm" onClick={() => relayActions.set(defaultRelays)}>
Select All
</Button>
<CustomRelayForm onSubmit={(url) => relayActions.push(url)} />
</Flex>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Add Custom
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat.
</AccordionPanel>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="brand" variant="solid">
Save
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@ -9,8 +9,8 @@ import identity from "../../services/identity";
import userContactsService from "../../services/user-contacts";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isNote } from "../../helpers/nostr-event";
import settings from "../../services/settings";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
function useExtendedContacts(pubkey: string) {
useAppTitle("discover");
@ -42,7 +42,7 @@ function useExtendedContacts(pubkey: string) {
export const DiscoverTab = () => {
const pubkey = useSubject(identity.pubkey);
const relays = useSubject(settings.relays);
const relays = useReadRelayUrls();
const contactsOfContacts = useExtendedContacts(pubkey);
const { events, loading, loadMore } = useTimelineLoader(

View File

@ -1,4 +1,4 @@
import { Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
import { Button, Flex, FormControl, FormLabel, Spinner, Switch } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom";
import moment from "moment";
import { Note } from "../../components/note";
@ -7,16 +7,16 @@ import useSubject from "../../hooks/use-subject";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts";
import identity from "../../services/identity";
import settings from "../../services/settings";
import { AddIcon } from "@chakra-ui/icons";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import { useReadonlyMode } from "../../hooks/use-readonly-mode";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
export const FollowingTab = () => {
const readonly = useReadonlyMode();
const pubkey = useSubject(identity.pubkey);
const relays = useSubject(settings.relays);
const relays = useReadRelayUrls();
const { openModal } = useContext(PostModalContext);
const contacts = useUserContacts(pubkey);
const [search, setSearch] = useSearchParams();

View File

@ -5,13 +5,12 @@ import { Note } from "../../components/note";
import { unique } from "../../helpers/array";
import { isNote } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import useSubject from "../../hooks/use-subject";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import settings from "../../services/settings";
export const GlobalTab = () => {
useAppTitle("global");
const defaultRelays = useSubject(settings.relays);
const defaultRelays = useReadRelayUrls();
const [searchParams, setSearchParams] = useSearchParams();
const selectedRelay = searchParams.get("relay") ?? "";
const setSelectedRelay = (url: string) => {

View File

@ -4,13 +4,13 @@ import { useNavigate } from "react-router-dom";
import { RelayUrlInput } from "../../components/relay-url-input";
import { normalizeToHex } from "../../helpers/nip-19";
import identity from "../../services/identity";
import settings from "../../services/settings";
import clientRelaysService from "../../services/client-relays";
export const LoginNpubView = () => {
const navigate = useNavigate();
const toast = useToast();
const [npub, setNpub] = useState("");
const [relay, setRelay] = useState("");
const [relayUrl, setRelayUrl] = useState("");
const handleSubmit: React.FormEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
@ -21,10 +21,8 @@ export const LoginNpubView = () => {
}
identity.loginWithPubkey(pubkey);
// TODO: the settings service should not be in charge of the relays
if (!settings.relays.value.includes(relay)) {
settings.relays.next([...settings.relays.value, relay]);
}
clientRelaysService.bootstrapRelays.add(relayUrl);
};
return (
@ -47,8 +45,8 @@ export const LoginNpubView = () => {
<RelayUrlInput
placeholder="wss://nostr.example.com"
isRequired
value={relay}
onChange={(e) => setRelay(e.target.value)}
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
/>
<FormHelperText>The first relay to connect to.</FormHelperText>
</FormControl>

View File

@ -2,14 +2,13 @@ import { Button, Card, CardBody, Flex, Spinner, Text } from "@chakra-ui/react";
import moment from "moment";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { NoteContents } from "../../components/note/note-contents";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { convertTimestampToDate } from "../../helpers/date";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useSubject from "../../hooks/use-subject";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import identity from "../../services/identity";
import settings from "../../services/settings";
import { NostrEvent } from "../../types/nostr-event";
const Kind1Notification = ({ event }: { event: NostrEvent }) => {
@ -39,11 +38,11 @@ const NotificationItem = memo(({ event }: { event: NostrEvent }) => {
});
const NotificationsView = () => {
const relays = useSubject(settings.relays);
const readRelays = useReadRelayUrls();
const pubkey = useSubject(identity.pubkey);
const { events, loading, loadMore } = useTimelineLoader(
"notifications",
relays,
readRelays,
{
"#p": [pubkey],
kinds: [1],

139
src/views/relays/index.tsx Normal file
View File

@ -0,0 +1,139 @@
import {
Button,
Flex,
FormControl,
FormLabel,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
IconButton,
Text,
Badge,
} from "@chakra-ui/react";
import { SyntheticEvent, useEffect, useState } from "react";
import { TrashIcon, UndoIcon } from "../../components/icons";
import useSubject from "../../hooks/use-subject";
import { RelayFavicon } from "../../components/relay-favicon";
import clientRelaysService from "../../services/client-relays";
import { RelayConfig, RelayMode } from "../../services/relays/relay";
import { useList } from "react-use";
import { RelayUrlInput } from "../../components/relay-url-input";
export const RelaysView = () => {
const relays = useSubject(clientRelaysService.relays);
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();
setRelayInputValue("");
const url = relayInputValue;
if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) {
addActions.push({ url, mode: RelayMode.ALL });
}
};
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 (
<Flex direction="column" pt="2" pb="2" overflow="auto">
<TableContainer mb="4">
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Url</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 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>
))}
</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={(e) => setRelayInputValue(e.target.value)}
isRequired
/>
<Button type="submit" isDisabled={saving}>
Add
</Button>
</Flex>
</FormControl>
</form>
<Flex justifyContent="flex-end" gap="2">
<Button type="submit" onClick={savePending} isDisabled={saving || !hasPending}>
Reset
</Button>
<Button type="submit" isLoading={saving} onClick={savePending} isDisabled={!hasPending}>
Save Changes
</Button>
</Flex>
</Flex>
);
};

View File

@ -5,14 +5,6 @@ import {
FormLabel,
Switch,
useColorMode,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
IconButton,
AccordionItem,
Accordion,
AccordionPanel,
@ -21,41 +13,20 @@ import {
AccordionIcon,
ButtonGroup,
FormHelperText,
Text,
} from "@chakra-ui/react";
import { SyntheticEvent, useState } from "react";
import { GlobalIcon, RelayIcon, TrashIcon } from "../../components/icons";
import { RelayStatus } from "./relay-status";
import { useState } from "react";
import useSubject from "../../hooks/use-subject";
import settings from "../../services/settings";
import { clearCacheData, deleteDatabase } from "../../services/db";
import { RelayUrlInput } from "../../components/relay-url-input";
import { useNavigate } from "react-router-dom";
import identity from "../../services/identity";
import { RelayFavicon } from "../../components/relay-favicon";
export const SettingsView = () => {
const navigate = useNavigate();
const relays = useSubject(settings.relays);
const blurImages = useSubject(settings.blurImages);
const autoShowMedia = useSubject(settings.autoShowMedia);
const proxyUserMedia = useSubject(settings.proxyUserMedia);
const [relayInputValue, setRelayInputValue] = useState("");
const { colorMode, setColorMode } = useColorMode();
const handleRemoveRelay = (url: string) => {
settings.relays.next(relays.filter((v) => v !== url));
};
const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
if (!relays.includes(relayInputValue)) {
settings.relays.next([...relays, relayInputValue]);
setRelayInputValue("");
}
};
const [clearing, setClearing] = useState(false);
const handleClearData = async () => {
setClearing(true);
@ -73,75 +44,6 @@ export const SettingsView = () => {
return (
<Flex direction="column" pt="2" pb="2" overflow="auto">
<Accordion defaultIndex={[0]} allowMultiple>
<AccordionItem>
<h2>
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Relays
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
<TableContainer mb="4">
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>Url</Th>
<Th>Status</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{relays.map((url) => (
<Tr key={url}>
<Td>
<Flex alignItems="center">
<RelayFavicon size="xs" relay={url} mr="2" />
<Text>{url}</Text>
</Flex>
</Td>
<Td>
<RelayStatus url={url} />
</Td>
<Td isNumeric>
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + url)}
size="sm"
aria-label="Global Feed"
mr="2"
/>
<IconButton
icon={<TrashIcon />}
title="Remove Relay"
aria-label="Remove Relay"
size="sm"
onClick={() => handleRemoveRelay(url)}
/>
</Td>
</Tr>
))}
</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={(e) => setRelayInputValue(e.target.value)}
isRequired
/>
<Button type="submit">Add</Button>
</Flex>
</FormControl>
</form>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<h2>
<AccordionButton>

View File

@ -1,5 +1,5 @@
import moment from "moment";
import { Flex, Grid, SkeletonText, Text } from "@chakra-ui/react";
import { Flex, Grid, SkeletonText } from "@chakra-ui/react";
import { UserCard } from "./components/user-card";
import { useUserContacts } from "../../hooks/use-user-contacts";

View File

@ -3,13 +3,12 @@ import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
import { isNote } from "../../helpers/nostr-event";
import useSubject from "../../hooks/use-subject";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import settings from "../../services/settings";
const UserNotesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useSubject(settings.relays);
const relays = useReadRelayUrls();
const { events, loading, loadMore } = useTimelineLoader(
`${pubkey} notes`,

View File

@ -1,49 +1,51 @@
import { useCallback } from "react";
import { SkeletonText, Text, Grid, Box, IconButton } from "@chakra-ui/react";
import settings from "../../services/settings";
import useSubject from "../../hooks/use-subject";
import { Text, Grid, Box, IconButton, Flex, Heading } from "@chakra-ui/react";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { useNavigate, useOutletContext } from "react-router-dom";
import { AddIcon, GlobalIcon } from "../../components/icons";
import { GlobalIcon } from "../../components/icons";
import { useUserRelays } from "../../hooks/use-user-relays";
const UserRelaysTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const contacts = useUserContacts(pubkey);
const userRelays = useUserRelays(pubkey);
const navigate = useNavigate();
const relays = useSubject(settings.relays);
const addRelay = useCallback(
(url: string) => {
settings.relays.next([...relays, url]);
},
[relays]
);
if (!contacts) {
return <SkeletonText />;
}
return (
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)" }} gap="2">
{Object.entries(contacts.relays).map(([url, opts]) => (
<Box key={url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{url}</Text>
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + url)}
size="sm"
aria-label="Global Feed"
/>
<IconButton
icon={<AddIcon />}
onClick={() => addRelay(url)}
isDisabled={relays.includes(url)}
size="sm"
aria-label="Add Relay"
/>
</Box>
))}
</Grid>
<Flex direction="column">
{userRelays && (
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)" }} gap="2">
{userRelays.relays.map((relayConfig) => (
<Box key={relayConfig.url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{relayConfig.url}</Text>
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + relayConfig.url)}
size="sm"
aria-label="Global Feed"
/>
</Box>
))}
</Grid>
)}
{contacts && (
<>
<Heading size="md">Relays from contact list (old)</Heading>
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)" }} gap="2">
{Object.entries(contacts.relays).map(([url, opts]) => (
<Box key={url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{url}</Text>
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + url)}
size="sm"
aria-label="Global Feed"
/>
</Box>
))}
</Grid>
</>
)}
</Flex>
);
};

View File

@ -3,13 +3,12 @@ import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { Note } from "../../components/note";
import { isReply } from "../../helpers/nostr-event";
import useSubject from "../../hooks/use-subject";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import settings from "../../services/settings";
const UserRepliesTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const relays = useSubject(settings.relays);
const relays = useReadRelayUrls();
const { events, loading, loadMore } = useTimelineLoader(
`${pubkey} replies`,