add offline mode

add simple 0 relay warning
fix multiple services not using local relay
fix bug in nostr-idb not supporting multiple filters
This commit is contained in:
hzrd149 2024-01-28 12:24:45 +00:00
parent cec7eff183
commit 31a649e867
32 changed files with 292 additions and 218 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add offline mode

View File

@ -29,8 +29,8 @@
"@emotion/styled": "^11.11.0",
"@getalby/bitcoin-connect": "^3.2.1",
"@getalby/bitcoin-connect-react": "^3.2.1",
"@noble/hashes": "^1.3.2",
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.3.2",
"@noble/secp256k1": "^1.7.0",
"@react-three/drei": "^9.92.5",
"@react-three/fiber": "^8.15.12",
@ -56,7 +56,7 @@
"match-sorter": "^6.3.1",
"nanoid": "^5.0.4",
"ngeohash": "^0.6.3",
"nostr-idb": "^2.0.0",
"nostr-idb": "^2.0.1",
"nostr-tools": "^2.1.3",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",

View File

@ -85,6 +85,7 @@ import VideosView from "./views/videos";
import VideoDetailsView from "./views/videos/video";
import BookmarksView from "./views/bookmarks";
import MailboxesView from "./views/mailboxes";
import RequireReadRelays from "./providers/route/require-read-relays";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));

View File

@ -19,7 +19,7 @@ export default class NostrSubscription {
onEvent = new Subject<NostrEvent>();
onEOSE = new Subject<IncomingEOSE>();
constructor(relayUrl: string, query?: NostrRequestFilter, name?: string) {
constructor(relayUrl: string | URL, query?: NostrRequestFilter, name?: string) {
this.id = nanoid();
this.query = query;
this.name = name;

View File

@ -1,3 +1,4 @@
import { offlineMode } from "../services/offline-mode";
import relayScoreboardService from "../services/relay-scoreboard";
import { RawIncomingNostrEvent, NostrEvent, CountResponse } from "../types/nostr-event";
import { NostrOutgoingMessage } from "../types/nostr-query";
@ -64,6 +65,8 @@ export default class Relay {
}
open() {
if (offlineMode.value) return;
if (this.okay) return;
this.intentionalClose = false;
this.ws = new WebSocket(this.url);
@ -166,7 +169,6 @@ export default class Relay {
}
handleMessage(event: MessageEvent<string>) {
// skip empty events
if (!event.data) return;
try {

View File

@ -22,7 +22,6 @@ import {
} from "../helpers/nostr/filter";
import { localRelay } from "../services/local-relay";
import { relayRequest } from "../helpers/relay";
import { Subscription } from "nostr-idb";
const BLOCK_SIZE = 100;

View File

@ -91,7 +91,11 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
<RawValue heading="NIP-19 Pointer" value={getSharableEventAddress(event)} />
</Section>
<Section label="Content" p="0" actions={<CopyIconButton aria-label="copy json" text={event.content} />}>
<Section
label="Content"
p="0"
actions={<CopyIconButton aria-label="copy json" text={event.content} size="sm" />}
>
<Code whiteSpace="pre" overflowX="auto" width="100%" p="4">
{event.content}
</Code>
@ -99,7 +103,7 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
<Section
label="JSON"
p="0"
actions={<CopyIconButton aria-label="copy json" text={JSON.stringify(event)} />}
actions={<CopyIconButton aria-label="copy json" text={JSON.stringify(event)} size="sm" />}
>
<JsonCode data={event} />
</Section>

View File

@ -1,5 +1,5 @@
import { useContext } from "react";
import { Avatar, Box, Button, Flex, FlexProps, Heading, LinkOverlay } from "@chakra-ui/react";
import { Avatar, Box, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { css } from "@emotion/react";
@ -9,6 +9,9 @@ import PublishLog from "../publish-log";
import NavItems from "./nav-items";
import { PostModalContext } from "../../providers/route/post-modal-provider";
import { WritingIcon } from "../icons";
import useSubject from "../../hooks/use-subject";
import { offlineMode } from "../../services/offline-mode";
import WifiOff from "../icons/wifi-off";
const hideScrollbar = css`
-ms-overflow-style: none;
@ -21,6 +24,7 @@ const hideScrollbar = css`
export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
const offline = useSubject(offlineMode);
return (
<Flex
@ -44,6 +48,14 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
noStrudel
</LinkOverlay>
</Heading>
{offline && (
<IconButton
aria-label="Disable offline mode"
title="Disable offline mode"
icon={<WifiOff boxSize={5} color="orange" />}
onClick={() => offlineMode.next(false)}
/>
)}
</Flex>
{account && (
<>

View File

@ -1,10 +1,13 @@
import { Button, Flex } from "@chakra-ui/react";
import { Button, Flex, FlexProps } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { safeRelayUrl } from "../../helpers/relay";
import { RelayUrlInput } from "../relay-url-input";
export default function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
export default function AddRelayForm({
onSubmit,
...props
}: { onSubmit: (relay: string) => void } & Omit<FlexProps, "children" | "onSubmit">) {
const { register, handleSubmit, reset } = useForm({
defaultValues: {
url: "",
@ -19,7 +22,7 @@ export default function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) =
});
return (
<Flex as="form" display="flex" gap="2" onSubmit={submit}>
<Flex as="form" display="flex" gap="2" onSubmit={submit} {...props}>
<RelayUrlInput {...register("url")} placeholder="wss://relay.example.com" size="sm" borderRadius="md" />
<Button type="submit" size="sm">
Add

View File

@ -0,0 +1,40 @@
import { useEffect } from "react";
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useDisclosure,
} from "@chakra-ui/react";
import { useReadRelays } from "../../hooks/use-client-relays";
export default function Setup() {
const relaysModal = useDisclosure();
const readRelays = useReadRelays();
useEffect(() => (readRelays.size === 0 ? relaysModal.onOpen() : relaysModal.onClose()), [readRelays]);
return (
<>
<Modal isOpen={relaysModal.isOpen} onClose={relaysModal.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Setup Relays</ModalHeader>
<ModalCloseButton />
<ModalBody></ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={relaysModal.onClose}>
Close
</Button>
<Button variant="ghost">Use Default</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View File

@ -1,8 +1,10 @@
export const SEARCH_RELAYS = [
import { safeRelayUrl, safeRelayUrls } from "./helpers/relay";
export const SEARCH_RELAYS = safeRelayUrls([
"wss://relay.nostr.band",
"wss://search.nos.today",
"wss://relay.noswhere.com",
// TODO: requires NIP-42 auth
// "wss://filter.nostr.wine",
];
export const COMMON_CONTACT_RELAY = "wss://purplepag.es";
]);
export const COMMON_CONTACT_RELAY = safeRelayUrl("wss://purplepag.es") as string;

View File

@ -11,13 +11,13 @@ export function getRelayVariations(relay: string) {
} else return [relay, relay + "/"];
}
export function validateRelayURL(relay: string) {
if (relay.includes(",ws")) throw new Error("Can not have multiple relays in one string");
const url = new URL(relay);
export function validateRelayURL(relay: string | URL) {
if (typeof relay === "string" && relay.includes(",ws")) throw new Error("Can not have multiple relays in one string");
const url = typeof relay === "string" ? new URL(relay) : relay;
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
return url;
}
export function isValidRelayURL(relay: string) {
export function isValidRelayURL(relay: string | URL) {
try {
validateRelayURL(relay);
return true;
@ -26,6 +26,7 @@ export function isValidRelayURL(relay: string) {
}
}
/** @deprecated */
export function normalizeRelayURL(relayUrl: string) {
const url = validateRelayURL(relayUrl);
url.pathname = url.pathname.replace(/\/+/g, "/");
@ -34,6 +35,8 @@ export function normalizeRelayURL(relayUrl: string) {
url.hash = "";
return url.toString();
}
/** @deprecated */
export function safeNormalizeRelayURL(relayUrl: string) {
try {
return normalizeRelayURL(relayUrl);
@ -43,7 +46,7 @@ export function safeNormalizeRelayURL(relayUrl: string) {
}
// TODO: move these to helpers/relay
export function safeRelayUrl(relayUrl: string) {
export function safeRelayUrl(relayUrl: string | URL) {
try {
return validateRelayURL(relayUrl).toString();
} catch (e) {

View File

@ -2,7 +2,7 @@ import "./polyfill";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import { GlobalProviders } from "./providers/global";
import "./services/controller";
import "./services/user-event-sync";
// setup bitcoin connect
import { init, onConnected } from "@getalby/bitcoin-connect-react";

View File

@ -1,12 +1,12 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
import { kinds } from "nostr-tools";
import { useReadRelays } from "../../hooks/use-client-relays";
import useCurrentAccount from "../../hooks/use-current-account";
import TimelineLoader from "../../classes/timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useUserInbox } from "../../hooks/use-user-mailboxes";
type DMTimelineContextType = {
timeline?: TimelineLoader;
@ -23,7 +23,7 @@ export function useDMTimeline() {
export default function DMTimelineProvider({ children }: PropsWithChildren) {
const account = useCurrentAccount();
const inbox = useReadRelays();
const inbox = useUserInbox(account?.pubkey);
const userMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(

View File

@ -3,6 +3,7 @@ import DeleteEventProvider from "./delete-event-provider";
import InvoiceModalProvider from "./invoice-modal";
import MuteModalProvider from "./mute-modal-provider";
import PostModalProvider from "./post-modal-provider";
import RequireReadRelays from "./require-read-relays";
/** Providers that provide functionality to pages (needs to be rendered under a router) */
export function RouteProviders({ children }: { children: React.ReactNode }) {
@ -11,7 +12,9 @@ export function RouteProviders({ children }: { children: React.ReactNode }) {
<MuteModalProvider>
<DebugModalProvider>
<InvoiceModalProvider>
<PostModalProvider>{children}</PostModalProvider>
<PostModalProvider>
<RequireReadRelays>{children}</RequireReadRelays>
</PostModalProvider>
</InvoiceModalProvider>
</DebugModalProvider>
</MuteModalProvider>

View File

@ -0,0 +1,36 @@
import { PropsWithChildren, useCallback } from "react";
import { Button, ButtonGroup, Flex, Heading } from "@chakra-ui/react";
import { useReadRelays } from "../../hooks/use-client-relays";
import clientRelaysService, { recommendedReadRelays, recommendedWriteRelays } from "../../services/client-relays";
import AddRelayForm from "../../components/relay-management-drawer/add-relay-form";
import { RelayMode } from "../../classes/relay";
import useSubject from "../../hooks/use-subject";
import { offlineMode } from "../../services/offline-mode";
export default function RequireReadRelays({ children }: PropsWithChildren) {
const readRelays = useReadRelays();
const offline = useSubject(offlineMode);
const setDefault = useCallback(() => {
clientRelaysService.readRelays.next(recommendedReadRelays);
clientRelaysService.writeRelays.next(recommendedWriteRelays);
clientRelaysService.saveRelays();
}, []);
if (readRelays.size === 0 && !offline)
return (
<Flex direction="column" maxW="md" mx="auto" h="full" alignItems="center" justifyContent="center" gap="4">
<Heading size="md">Looks like you don't have any relays setup</Heading>
<AddRelayForm onSubmit={(url) => clientRelaysService.addRelay(url, RelayMode.ALL)} w="full" />
<ButtonGroup ml="auto">
<Button onClick={() => offlineMode.next(true)}>Offline mode</Button>
<Button colorScheme="primary" onClick={setDefault}>
Use recommended
</Button>
</ButtonGroup>
</Flex>
);
return children;
}

View File

@ -1,123 +1,4 @@
import { secp256k1 } from "@noble/curves/secp256k1";
import { ProjPointType } from "@noble/curves/abstract/weierstrass";
import { bytesToHex, randomBytes } from "@noble/hashes/utils";
import {
BlindedMessageData,
CashuMint,
CashuWallet,
MintKeys,
Proof,
SerializedBlindedMessage,
getEncodedToken,
getDecodedToken,
SerializedBlindedSignature,
} from "@cashu/cashu-ts";
import { bytesToNumber, splitAmount } from "@cashu/cashu-ts/dist/lib/es6/utils";
import { hashToCurve, pointFromHex, unblindSignature } from "@cashu/cashu-ts/dist/lib/es6/DHKE";
import { BlindedMessage } from "@cashu/cashu-ts/dist/lib/es6/model/BlindedMessage";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
/** Copied from @cashu/cashu-ts/src/DHKE and modified to use textDecoder instead of encodeUint8toBase64 */
function blindMessage(secret: Uint8Array, r?: bigint): { B_: ProjPointType<bigint>; r: bigint } {
const secretMessageBase64 = textDecoder.decode(secret); //encodeUint8toBase64(secret);
const secretMessage = new TextEncoder().encode(secretMessageBase64);
const Y = hashToCurve(secretMessage);
if (!r) {
r = bytesToNumber(secp256k1.utils.randomPrivateKey());
}
const rG = secp256k1.ProjectivePoint.BASE.multiply(r);
const B_ = Y.add(rG);
return { B_, r };
}
/** Copied from @cashu/cashu-ts/src/DHKE and modified to use textDecoder instead of encodeUint8toBase64 */
function constructProofs(
promises: Array<SerializedBlindedSignature>,
rs: Array<bigint>,
secrets: Array<Uint8Array>,
keys: MintKeys,
): Array<Proof> {
return promises.map((p: SerializedBlindedSignature, i: number) => {
const C_ = pointFromHex(p.C_);
const A = pointFromHex(keys[p.amount]);
const C = unblindSignature(C_, rs[i], A);
const proof = {
id: p.id,
amount: p.amount,
secret: textDecoder.decode(secrets[i]), // encodeUint8toBase64(secrets[i]),
C: C.toHex(true),
};
return proof;
});
}
class P2PKCashuWallet extends CashuWallet {
p2pkCreateRandomBlindedMessages(amount: number, pubkey: string): BlindedMessageData & { amounts: Array<number> } {
const amounts = splitAmount(amount);
return this.p2pkCreateBlindedMessages(amounts, pubkey);
}
p2pkCreateBlindedMessages(amounts: Array<number>, pubkey: string): BlindedMessageData & { amounts: Array<number> } {
const blindedMessages: Array<SerializedBlindedMessage> = [];
const secrets: Array<Uint8Array> = [];
const rs: Array<bigint> = [];
for (let i = 0; i < amounts.length; i++) {
let deterministicR = undefined;
let secret = undefined;
secret = textEncoder.encode(
JSON.stringify([
"P2PK",
{
// NOTE: the order is very important for the token to work with nutshell
// This can be removed when nutshell no longer re-encodes the secret when checking the sig
data: pubkey,
nonce: bytesToHex(randomBytes(16)),
},
])
.replaceAll(/,/g, ", ")
.replaceAll(/:/g, ": "),
);
secrets.push(secret);
const { B_, r } = blindMessage(secret, deterministicR);
rs.push(r);
const blindedMessage = new BlindedMessage(amounts[i], B_);
blindedMessages.push(blindedMessage.getSerializedBlindedMessage());
}
return { blindedMessages, secrets, rs, amounts };
}
async p2pkRequestTokens(
amount: number,
id: string,
pubkey: string,
): Promise<{ proofs: Array<Proof>; newKeys?: MintKeys }> {
const { blindedMessages, secrets, rs } = this.p2pkCreateRandomBlindedMessages(amount, pubkey);
const payloads = { outputs: blindedMessages };
const { promises } = await this.mint.mint(payloads, id);
return {
proofs: constructProofs(
promises,
rs,
secrets,
//@ts-ignore
await this.getKeys(promises),
),
//@ts-ignore
newKeys: await this.changedKeys(promises),
};
}
}
//@ts-ignore
const wallet = new P2PKCashuWallet(new CashuMint("https://8333.space:3338"));
//@ts-ignore
window.wallet = wallet;
//@ts-ignore
window.getDecodedToken = getDecodedToken;
//@ts-ignore
window.getEncodedToken = getEncodedToken;
import { CashuMint } from "@cashu/cashu-ts";
const mints = new Map<string, CashuMint>();

View File

@ -8,6 +8,14 @@ import { NostrEvent } from "nostr-tools";
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
export const recommendedReadRelays = new RelaySet([
"wss://relay.damus.io/",
"wss://nostr.wine/",
"wss://relay.snort.social/",
"wss://nos.lol/",
]);
export const recommendedWriteRelays = new RelaySet(["wss://relay.damus.io/", "wss://nos.lol/"]);
class ClientRelayService {
readRelays = new PersistentSubject(new RelaySet());
writeRelays = new PersistentSubject(new RelaySet());

View File

@ -1,11 +0,0 @@
import accountService from "./account";
import clientRelaysService from "./client-relays";
import userAppSettings from "./settings/user-app-settings";
import userMailboxesService from "./user-mailboxes";
accountService.current.subscribe((account) => {
if (!account) return;
const relays = clientRelaysService.readRelays.value;
userMailboxesService.requestMailboxes(account.pubkey, relays, { alwaysRequest: true });
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
});

View File

@ -1,9 +1,11 @@
import { kinds, nip25 } from "nostr-tools";
import { Filter, kinds, nip25 } from "nostr-tools";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import { localRelay } from "./local-relay";
import { relayRequest } from "../helpers/relay";
type eventId = string;
type relay = string;
@ -24,7 +26,7 @@ class EventReactionsService {
return subject;
}
handleEvent(event: NostrEvent) {
handleEvent(event: NostrEvent, cache = true) {
if (event.kind !== kinds.Reaction) return;
const pointer = nip25.getReactedEventPointer(event);
if (!pointer?.id) return;
@ -35,6 +37,8 @@ class EventReactionsService {
} else if (!subject.value.some((e) => e.id === event.id)) {
subject.next([...subject.value, event]);
}
if (cache) localRelay.publish(event);
}
batchRequests() {
@ -49,9 +53,14 @@ class EventReactionsService {
}
for (const [relay, ids] of Object.entries(idsFromRelays)) {
const filter: Filter = { "#e": ids, kinds: [kinds.Reaction] };
// load from local relay
relayRequest(localRelay, [filter]).then((events) => events.forEach((e) => this.handleEvent(e)));
const request = new NostrRequest([relay]);
request.onEvent.subscribe(this.handleEvent, this);
request.start({ "#e": ids, kinds: [kinds.Reaction] });
request.start(filter);
}
this.pending.clear();
}

View File

@ -1,11 +1,12 @@
import { kinds } from "nostr-tools";
import { Filter, kinds } from "nostr-tools";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent, isATag, isETag } from "../types/nostr-event";
import { NostrRequestFilter } from "../types/nostr-query";
import { isHexKey } from "../helpers/nip19";
import { relayRequest } from "../helpers/relay";
import { localRelay } from "./local-relay";
type eventUID = string;
type relay = string;
@ -26,7 +27,7 @@ class EventZapsService {
return subject;
}
handleEvent(event: NostrEvent) {
handleEvent(event: NostrEvent, cache = true) {
if (event.kind !== kinds.Zap) return;
const eventUID = event.tags.find(isETag)?.[1] ?? event.tags.find(isATag)?.[1];
if (!eventUID) return;
@ -37,6 +38,8 @@ class EventZapsService {
} else if (!subject.value.some((e) => e.id === event.id)) {
subject.next([...subject.value, event]);
}
if (cache) localRelay.publish(event);
}
batchRequests() {
@ -56,13 +59,12 @@ class EventZapsService {
const eventIds = ids.filter(isHexKey);
const coordinates = ids.filter((id) => id.includes(":"));
const queries: NostrRequestFilter = [];
if (eventIds.length > 0) {
queries.push({ "#e": eventIds, kinds: [kinds.Zap] });
}
if (coordinates.length > 0) {
queries.push({ "#a": coordinates, kinds: [kinds.Zap] });
}
const queries: Filter[] = [];
if (eventIds.length > 0) queries.push({ "#e": eventIds, kinds: [kinds.Zap] });
if (coordinates.length > 0) queries.push({ "#a": coordinates, kinds: [kinds.Zap] });
// load from local relay
relayRequest(localRelay, queries).then((events) => events.forEach((e) => this.handleEvent(e, false)));
request.start(queries);
}

View File

@ -0,0 +1,4 @@
import { PersistentSubject } from "../classes/subject";
export const offlineMode = new PersistentSubject(localStorage.getItem("offline-mode") === "true");
offlineMode.subscribe((v) => localStorage.setItem("offline-mode", v ? "true" : "false"));

View File

@ -1,6 +1,7 @@
import db from "./db";
import { fetchWithCorsFallback } from "../helpers/cors";
import { isHexKey } from "../helpers/nip19";
import { validateRelayURL } from "../helpers/relay";
export type RelayInformationDocument = {
name: string;
@ -22,7 +23,7 @@ function sanitizeInfo(info: RelayInformationDocument) {
}
async function fetchInfo(relay: string) {
const url = new URL(relay);
const url = validateRelayURL(relay);
url.protocol = url.protocol === "ws:" ? "http" : "https";
const infoDoc = await fetchWithCorsFallback(url, { headers: { Accept: "application/nostr+json" } }).then(
@ -39,11 +40,12 @@ async function fetchInfo(relay: string) {
const memoryCache = new Map<string, RelayInformationDocument>();
async function getInfo(relay: string) {
if (memoryCache.has(relay)) return memoryCache.get(relay)!;
const url = validateRelayURL(relay).toString();
if (memoryCache.has(url)) return memoryCache.get(url)!;
const cached = await db.get("relayInfo", relay);
const cached = await db.get("relayInfo", url);
if (cached) {
memoryCache.set(relay, cached);
memoryCache.set(url, cached);
return cached as RelayInformationDocument;
}

View File

@ -1,7 +1,8 @@
import Relay from "../classes/relay";
import Subject from "../classes/subject";
import { logger } from "../helpers/debug";
import { normalizeRelayURL } from "../helpers/relay";
import { safeRelayUrl, validateRelayURL } from "../helpers/relay";
import { offlineMode } from "./offline-mode";
export class RelayPoolService {
relays = new Map<string, Relay>();
@ -13,23 +14,25 @@ export class RelayPoolService {
getRelays() {
return Array.from(this.relays.values());
}
getRelayClaims(url: string) {
const normalized = normalizeRelayURL(url);
if (!this.relayClaims.has(normalized)) {
this.relayClaims.set(normalized, new Set());
getRelayClaims(url: string | URL) {
url = validateRelayURL(url);
const key = url.toString();
if (!this.relayClaims.has(key)) {
this.relayClaims.set(key, new Set());
}
return this.relayClaims.get(normalized) as Set<any>;
return this.relayClaims.get(key) as Set<any>;
}
requestRelay(url: string, connect = true) {
const normalized = normalizeRelayURL(url);
if (!this.relays.has(normalized)) {
const newRelay = new Relay(normalized);
this.relays.set(normalized, newRelay);
requestRelay(url: string | URL, connect = true) {
url = validateRelayURL(url);
const key = url.toString();
if (!this.relays.has(key)) {
const newRelay = new Relay(key);
this.relays.set(key, newRelay);
this.onRelayCreated.next(newRelay);
}
const relay = this.relays.get(normalized) as Relay;
const relay = this.relays.get(key) as Relay;
if (connect && !relay.okay) {
try {
relay.open();
@ -50,6 +53,8 @@ export class RelayPoolService {
}
}
reconnectRelays() {
if (offlineMode.value) return;
for (const [url, relay] of this.relays.entries()) {
const claims = this.getRelayClaims(url).size;
if (!relay.okay && claims > 0) {
@ -64,13 +69,15 @@ export class RelayPoolService {
}
// id can be anything
addClaim(url: string, id: any) {
const normalized = normalizeRelayURL(url);
this.getRelayClaims(normalized).add(id);
addClaim(url: string | URL, id: any) {
url = validateRelayURL(url);
const key = url.toString();
this.getRelayClaims(key).add(id);
}
removeClaim(url: string, id: any) {
const normalized = normalizeRelayURL(url);
this.getRelayClaims(normalized).delete(id);
removeClaim(url: string | URL, id: any) {
url = validateRelayURL(url);
const key = url.toString();
this.getRelayClaims(key).delete(id);
}
get connectedCount() {
@ -97,6 +104,14 @@ document.addEventListener("visibilitychange", () => {
}
});
offlineMode.subscribe((offline) => {
if (offline) {
for (const [_, relay] of relayPoolService.relays) {
relay.close();
}
}
});
if (import.meta.env.DEV) {
// @ts-ignore
window.relayPoolService = relayPoolService;

View File

@ -5,7 +5,6 @@ import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import relayInfoService from "./relay-info";
import { normalizeRelayURL } from "../helpers/relay";
import { localRelay } from "./local-relay";
import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats";
@ -45,7 +44,6 @@ class RelayStatsService {
}
requestSelfReported(relay: string) {
relay = normalizeRelayURL(relay);
const sub = this.selfReported.get(relay);
if (sub.value === undefined) {
@ -62,7 +60,6 @@ class RelayStatsService {
}
requestMonitorStats(relay: string) {
relay = normalizeRelayURL(relay);
const sub = this.monitorStats.get(relay);
if (sub.value === undefined) {

View File

@ -183,8 +183,10 @@ class ReplaceableEventLoaderService {
private async readFromCache() {
if (this.readFromCachePromises.size === 0) return;
const loading = new Map<string, Deferred<boolean>>();
const kindFilters: Record<number, Filter> = {};
for (const [cord] of this.readFromCachePromises) {
for (const [cord, p] of this.readFromCachePromises) {
const [kindStr, pubkey, d] = cord.split(":") as [string, string] | [string, string, string];
const kind = parseInt(kindStr);
kindFilters[kind] = kindFilters[kind] || { kinds: [kind] };
@ -196,34 +198,35 @@ class ReplaceableEventLoaderService {
const arr = (kindFilters[kind]["#d"] = kindFilters[kind]["#d"] || []);
arr.push(d);
}
loading.set(cord, p);
}
const filters = Array.from(Object.values(kindFilters));
for (const [cord] of loading) this.readFromCachePromises.delete(cord);
const events = await relayRequest(localRelay, filters);
for (const event of events) {
this.handleEvent(event, false);
const cord = getEventCoordinate(event);
const promise = this.readFromCachePromises.get(cord);
const promise = loading.get(cord);
if (promise) promise.resolve(true);
this.readFromCachePromises.delete(cord);
loading.delete(cord);
}
// resolve remaining promises
for (const [_, promise] of this.readFromCachePromises) promise.resolve();
this.readFromCachePromises.clear();
for (const [_, promise] of loading) promise.resolve();
if (events.length > 0) this.dbLog(`Read ${events.length} events from database`);
}
private loadCacheDedupe = new Map<string, Promise<boolean>>();
loadFromCache(cord: string) {
const dedupe = this.loadCacheDedupe.get(cord);
const dedupe = this.readFromCachePromises.get(cord);
if (dedupe) return dedupe;
// add to read queue
const promise = createDefer<boolean>();
this.readFromCachePromises.set(cord, promise);
this.loadCacheDedupe.set(cord, promise);
this.readFromCacheThrottle();
return promise;

View File

@ -71,12 +71,12 @@ class UserContactsService {
return sub;
}
/** @deprecated */
receiveEvent(event: NostrEvent) {
replaceableEventLoaderService.handleEvent(event);
}
}
/** @deprecated */
const userContactsService = new UserContactsService();
if (import.meta.env.DEV) {

View File

@ -0,0 +1,41 @@
import { COMMON_CONTACT_RELAY } from "../const";
import { logger } from "../helpers/debug";
import accountService from "./account";
import clientRelaysService from "./client-relays";
import { offlineMode } from "./offline-mode";
import userAppSettings from "./settings/user-app-settings";
import userContactsService from "./user-contacts";
import userMailboxesService from "./user-mailboxes";
import userMetadataService from "./user-metadata";
const log = logger.extend("user-event-sync");
function loadContactsList() {
const account = accountService.current.value!;
log("Loading contacts list");
userContactsService.requestContacts(account.pubkey, [...clientRelaysService.readRelays.value, COMMON_CONTACT_RELAY], {
alwaysRequest: true,
});
}
function downloadEvents() {
const account = accountService.current.value!;
const relays = clientRelaysService.readRelays.value;
log("Loading user information");
userMetadataService.requestMetadata(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
userMailboxesService.requestMailboxes(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true });
userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true });
loadContactsList();
}
accountService.current.subscribe((account) => {
if (!account) return;
downloadEvents();
});
offlineMode.subscribe((offline) => {
if (!offline && accountService.current.value) downloadEvents();
});

View File

@ -10,7 +10,7 @@ import replaceableEventLoaderService, { RequestOptions } from "./replaceable-eve
const WRITE_USER_SEARCH_BATCH_TIME = 500;
class UserMetadataService {
private parsedSubjects = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
private metadata = new SuperMap<string, Subject<Kind0ParsedContent>>((pubkey) => {
const sub = new Subject<Kind0ParsedContent>();
sub.subscribe((metadata) => {
if (metadata) {
@ -21,10 +21,10 @@ class UserMetadataService {
return sub;
});
getSubject(pubkey: string) {
return this.parsedSubjects.get(pubkey);
return this.metadata.get(pubkey);
}
requestMetadata(pubkey: string, relays: Iterable<string>, opts: RequestOptions = {}) {
const sub = this.parsedSubjects.get(pubkey);
const sub = this.metadata.get(pubkey);
const requestSub = replaceableEventLoaderService.requestEvent(relays, kinds.Metadata, pubkey, undefined, opts);
sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event)));
return sub;

View File

@ -12,12 +12,15 @@ import { ErrorBoundary } from "../../components/error-boundary";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { isValidRelayURL } from "../../helpers/relay";
import { useReadRelays, useWriteRelays } from "../../hooks/use-client-relays";
import { offlineMode } from "../../services/offline-mode";
import useSubject from "../../hooks/use-subject";
export default function RelaysView() {
const [search, setSearch] = useState("");
const deboundedSearch = useDeferredValue(search);
const isSearching = deboundedSearch.length > 2;
const addRelayModal = useDisclosure();
const offline = useSubject(offlineMode);
const readRelays = useReadRelays();
const writeRelays = useWriteRelays();
@ -43,6 +46,7 @@ export default function RelaysView() {
<VerticalPageLayout>
<Flex alignItems="center" gap="2" wrap="wrap">
<Input type="search" placeholder="search" value={search} onChange={(e) => setSearch(e.target.value)} w="auto" />
{!offline && <Button onClick={() => offlineMode.next(true)}>Offline</Button>}
<Spacer />
<Button as={RouterLink} to="/relays/popular">
Popular Relays

View File

@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Divider, Flex, Heading, SimpleGrid } from "@chakra-ui/react";
import { Divider, Flex, Heading, SimpleGrid, Switch } from "@chakra-ui/react";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
@ -19,11 +19,13 @@ import { useAppTitle } from "../../hooks/use-app-title";
import { NostrEvent } from "../../types/nostr-event";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import { useRouteStateBoolean } from "../../hooks/use-route-state-value";
function StreamsPage() {
useAppTitle("Streams");
const relays = useRelaySelectionRelays();
const userMuteFilter = useClientSideMuteFilter();
const showEnded = useRouteStateBoolean("ended", false);
const eventFilter = useCallback(
(event: NostrEvent) => {
@ -56,8 +58,11 @@ function StreamsPage() {
return (
<VerticalPageLayout>
<Flex gap="2" wrap="wrap">
<Flex gap="2" wrap="wrap" alignItems="center">
<PeopleListSelection />
<Switch checked={showEnded.isOpen} onChange={showEnded.onToggle}>
Show Ended
</Switch>
<RelaySelectionButton ml="auto" />
</Flex>
<IntersectionObserverProvider callback={callback}>
@ -69,14 +74,18 @@ function StreamsPage() {
<StreamCard key={stream.event.id} stream={stream} />
))}
</SimpleGrid>
<Heading size="lg" mt="4">
Ended
</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{endedStreams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} />
))}
</SimpleGrid>
{showEnded.isOpen && (
<>
<Heading size="lg" mt="4">
Ended
</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{endedStreams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} />
))}
</SimpleGrid>
</>
)}
<TimelineActionAndStatus timeline={timeline} />
</IntersectionObserverProvider>
</VerticalPageLayout>

View File

@ -5261,10 +5261,10 @@ normalize-package-data@^2.5.0:
semver "2 || 3 || 4 || 5"
validate-npm-package-license "^3.0.1"
nostr-idb@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/nostr-idb/-/nostr-idb-2.0.0.tgz#0fdce309613ffb19d12de64d3ffb050e0c7df208"
integrity sha512-qLflqSVaK02ClRXMUdNMOd6DRJZALgoHx+Y8Y+TOnnBc4utq8akPrsdjkCit2+N02PklcJh2kCIZSP9fl7cHJw==
nostr-idb@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/nostr-idb/-/nostr-idb-2.0.1.tgz#701cb0b69f93bbcdb55bb746920767cf0026a7c7"
integrity sha512-4FT9cmfq2FYWfMO+0DrkxeS7/aI3mcijBxuVQalx17se0nVkTVXV1WT21sM5eaCBZOQld41LkwBGc4lyXhktsw==
dependencies:
debug "^4.3.4"
idb "^8.0.0"