From 31a649e86718c2b089dfd14c0a8c2e426d73f22b Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sun, 28 Jan 2024 12:24:45 +0000 Subject: [PATCH] add offline mode add simple 0 relay warning fix multiple services not using local relay fix bug in nostr-idb not supporting multiple filters --- .changeset/bright-shirts-explain.md | 5 + package.json | 4 +- src/app.tsx | 1 + src/classes/nostr-subscription.ts | 2 +- src/classes/relay.ts | 4 +- src/classes/timeline-loader.ts | 1 - .../debug-modal/event-debug-modal.tsx | 8 +- src/components/layout/desktop-side-nav.tsx | 14 +- .../add-relay-form.tsx | 9 +- src/components/setup/index.tsx | 40 ++++++ src/const.ts | 8 +- src/helpers/relay.ts | 13 +- src/index.tsx | 2 +- src/providers/global/dm-timeline.tsx | 4 +- src/providers/route/index.tsx | 5 +- src/providers/route/require-read-relays.tsx | 36 ++++++ src/services/cashu-mints.ts | 121 +----------------- src/services/client-relays.ts | 8 ++ src/services/controller.ts | 11 -- src/services/event-reactions.ts | 15 ++- src/services/event-zaps.ts | 22 ++-- src/services/offline-mode.ts | 4 + src/services/relay-info.ts | 10 +- src/services/relay-pool.ts | 51 +++++--- src/services/relay-stats.ts | 3 - src/services/replaceable-event-requester.ts | 19 +-- src/services/user-contacts.ts | 2 +- src/services/user-event-sync.ts | 41 ++++++ src/services/user-metadata.ts | 6 +- src/views/relays/index.tsx | 4 + src/views/streams/index.tsx | 29 +++-- yarn.lock | 8 +- 32 files changed, 292 insertions(+), 218 deletions(-) create mode 100644 .changeset/bright-shirts-explain.md create mode 100644 src/components/setup/index.tsx create mode 100644 src/providers/route/require-read-relays.tsx delete mode 100644 src/services/controller.ts create mode 100644 src/services/offline-mode.ts create mode 100644 src/services/user-event-sync.ts diff --git a/.changeset/bright-shirts-explain.md b/.changeset/bright-shirts-explain.md new file mode 100644 index 000000000..b52cd537c --- /dev/null +++ b/.changeset/bright-shirts-explain.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add offline mode diff --git a/package.json b/package.json index 5bd52a0bd..6bfe1d258 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.tsx b/src/app.tsx index 30cdea4e2..b6de3a9c3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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")); diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index 7ac855f8c..fbd9831e5 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -19,7 +19,7 @@ export default class NostrSubscription { onEvent = new Subject(); onEOSE = new Subject(); - constructor(relayUrl: string, query?: NostrRequestFilter, name?: string) { + constructor(relayUrl: string | URL, query?: NostrRequestFilter, name?: string) { this.id = nanoid(); this.query = query; this.name = name; diff --git a/src/classes/relay.ts b/src/classes/relay.ts index 3a04ea8c3..068d4665f 100644 --- a/src/classes/relay.ts +++ b/src/classes/relay.ts @@ -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) { - // skip empty events if (!event.data) return; try { diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index 73d9a8238..4a8000635 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -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; diff --git a/src/components/debug-modal/event-debug-modal.tsx b/src/components/debug-modal/event-debug-modal.tsx index 0128f96d3..06f394592 100644 --- a/src/components/debug-modal/event-debug-modal.tsx +++ b/src/components/debug-modal/event-debug-modal.tsx @@ -91,7 +91,11 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent -
}> +
} + > {event.content} @@ -99,7 +103,7 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
} + actions={} >
diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx index f9123b021..2e326d78a 100644 --- a/src/components/layout/desktop-side-nav.tsx +++ b/src/components/layout/desktop-side-nav.tsx @@ -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) { const account = useCurrentAccount(); const { openModal } = useContext(PostModalContext); + const offline = useSubject(offlineMode); return ( ) { noStrudel + {offline && ( + } + onClick={() => offlineMode.next(false)} + /> + )} {account && ( <> diff --git a/src/components/relay-management-drawer/add-relay-form.tsx b/src/components/relay-management-drawer/add-relay-form.tsx index 3ef2e0470..bd590ae76 100644 --- a/src/components/relay-management-drawer/add-relay-form.tsx +++ b/src/components/relay-management-drawer/add-relay-form.tsx @@ -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) { const { register, handleSubmit, reset } = useForm({ defaultValues: { url: "", @@ -19,7 +22,7 @@ export default function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) = }); return ( - + + + + + + + ); +} diff --git a/src/const.ts b/src/const.ts index b9564750b..7b7165092 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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; diff --git a/src/helpers/relay.ts b/src/helpers/relay.ts index af9573034..5286f5579 100644 --- a/src/helpers/relay.ts +++ b/src/helpers/relay.ts @@ -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) { diff --git a/src/index.tsx b/src/index.tsx index a8a957afe..5d3b230f8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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"; diff --git a/src/providers/global/dm-timeline.tsx b/src/providers/global/dm-timeline.tsx index 0828c966a..dae12e516 100644 --- a/src/providers/global/dm-timeline.tsx +++ b/src/providers/global/dm-timeline.tsx @@ -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( diff --git a/src/providers/route/index.tsx b/src/providers/route/index.tsx index 033cc0db0..3a04b5b1b 100644 --- a/src/providers/route/index.tsx +++ b/src/providers/route/index.tsx @@ -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 }) { - {children} + + {children} + diff --git a/src/providers/route/require-read-relays.tsx b/src/providers/route/require-read-relays.tsx new file mode 100644 index 000000000..72a2b9ff3 --- /dev/null +++ b/src/providers/route/require-read-relays.tsx @@ -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 ( + + Looks like you don't have any relays setup + clientRelaysService.addRelay(url, RelayMode.ALL)} w="full" /> + + + + + + ); + + return children; +} diff --git a/src/services/cashu-mints.ts b/src/services/cashu-mints.ts index bd8183285..e2d5b0bb6 100644 --- a/src/services/cashu-mints.ts +++ b/src/services/cashu-mints.ts @@ -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; 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, - rs: Array, - secrets: Array, - keys: MintKeys, -): Array { - 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 } { - const amounts = splitAmount(amount); - return this.p2pkCreateBlindedMessages(amounts, pubkey); - } - p2pkCreateBlindedMessages(amounts: Array, pubkey: string): BlindedMessageData & { amounts: Array } { - const blindedMessages: Array = []; - const secrets: Array = []; - const rs: Array = []; - 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; 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(); diff --git a/src/services/client-relays.ts b/src/services/client-relays.ts index 296b985e2..ede286ffd 100644 --- a/src/services/client-relays.ts +++ b/src/services/client-relays.ts @@ -8,6 +8,14 @@ import { NostrEvent } from "nostr-tools"; export type RelayDirectory = Record; +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()); diff --git a/src/services/controller.ts b/src/services/controller.ts deleted file mode 100644 index e43ce8407..000000000 --- a/src/services/controller.ts +++ /dev/null @@ -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 }); -}); diff --git a/src/services/event-reactions.ts b/src/services/event-reactions.ts index 280ec3b3a..2992cc8ac 100644 --- a/src/services/event-reactions.ts +++ b/src/services/event-reactions.ts @@ -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(); } diff --git a/src/services/event-zaps.ts b/src/services/event-zaps.ts index e6c7d69f6..7fc2ae512 100644 --- a/src/services/event-zaps.ts +++ b/src/services/event-zaps.ts @@ -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); } diff --git a/src/services/offline-mode.ts b/src/services/offline-mode.ts new file mode 100644 index 000000000..4eaec2350 --- /dev/null +++ b/src/services/offline-mode.ts @@ -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")); diff --git a/src/services/relay-info.ts b/src/services/relay-info.ts index 0abcc6099..001145773 100644 --- a/src/services/relay-info.ts +++ b/src/services/relay-info.ts @@ -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(); 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; } diff --git a/src/services/relay-pool.ts b/src/services/relay-pool.ts index 21fcce364..78b4005a4 100644 --- a/src/services/relay-pool.ts +++ b/src/services/relay-pool.ts @@ -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(); @@ -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; + return this.relayClaims.get(key) as Set; } - 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; diff --git a/src/services/relay-stats.ts b/src/services/relay-stats.ts index 876886413..06bce9bf0 100644 --- a/src/services/relay-stats.ts +++ b/src/services/relay-stats.ts @@ -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) { diff --git a/src/services/replaceable-event-requester.ts b/src/services/replaceable-event-requester.ts index 1eb05380c..9876310a0 100644 --- a/src/services/replaceable-event-requester.ts +++ b/src/services/replaceable-event-requester.ts @@ -183,8 +183,10 @@ class ReplaceableEventLoaderService { private async readFromCache() { if (this.readFromCachePromises.size === 0) return; + const loading = new Map>(); + const kindFilters: Record = {}; - 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>(); 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(); this.readFromCachePromises.set(cord, promise); - this.loadCacheDedupe.set(cord, promise); this.readFromCacheThrottle(); return promise; diff --git a/src/services/user-contacts.ts b/src/services/user-contacts.ts index 75ae4ae39..2b8c714a0 100644 --- a/src/services/user-contacts.ts +++ b/src/services/user-contacts.ts @@ -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) { diff --git a/src/services/user-event-sync.ts b/src/services/user-event-sync.ts new file mode 100644 index 000000000..466af89c9 --- /dev/null +++ b/src/services/user-event-sync.ts @@ -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(); +}); diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts index f14529883..23e872de8 100644 --- a/src/services/user-metadata.ts +++ b/src/services/user-metadata.ts @@ -10,7 +10,7 @@ import replaceableEventLoaderService, { RequestOptions } from "./replaceable-eve const WRITE_USER_SEARCH_BATCH_TIME = 500; class UserMetadataService { - private parsedSubjects = new SuperMap>((pubkey) => { + private metadata = new SuperMap>((pubkey) => { const sub = new Subject(); 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, 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; diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 1a8987503..e4d965ff9 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -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() { setSearch(e.target.value)} w="auto" /> + {!offline && }