diff --git a/.changeset/quick-garlics-work.md b/.changeset/quick-garlics-work.md new file mode 100644 index 000000000..61c1f8385 --- /dev/null +++ b/.changeset/quick-garlics-work.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add local relay cache option diff --git a/src/classes/nostr-multi-subscription.ts b/src/classes/nostr-multi-subscription.ts index 2c0b16901..cc01c2e1c 100644 --- a/src/classes/nostr-multi-subscription.ts +++ b/src/classes/nostr-multi-subscription.ts @@ -29,10 +29,14 @@ export default class NostrMultiSubscription { this.id = nanoid(); this.name = name; } - private handleEvent(event: IncomingEvent) { - if (this.state === NostrMultiSubscription.OPEN && event.subId === this.id && !this.seenEvents.has(event.body.id)) { - this.onEvent.next(event.body); - this.seenEvents.add(event.body.id); + private handleEvent(incomingEvent: IncomingEvent) { + if ( + this.state === NostrMultiSubscription.OPEN && + incomingEvent.subId === this.id && + !this.seenEvents.has(incomingEvent.body.id) + ) { + this.onEvent.next(incomingEvent.body); + this.seenEvents.add(incomingEvent.body.id); } } diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index ae3c8e0b2..7ac855f8c 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -1,9 +1,10 @@ +import { nanoid } from "nanoid"; + import { NostrEvent } from "../types/nostr-event"; import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query"; import Relay, { IncomingEOSE } from "./relay"; import relayPoolService from "../services/relay-pool"; import { Subject } from "./subject"; -import { nanoid } from "nanoid"; export default class NostrSubscription { static INIT = "initial"; @@ -26,7 +27,9 @@ export default class NostrSubscription { this.relay = relayPoolService.requestRelay(relayUrl); this.onEvent.connectWithHandler(this.relay.onEvent, (event, next) => { - if (this.state === NostrSubscription.OPEN) next(event.body); + if (this.state === NostrSubscription.OPEN) { + next(event.body); + } }); this.onEOSE.connectWithHandler(this.relay.onEOSE, (eose, next) => { if (this.state === NostrSubscription.OPEN) next(eose); diff --git a/src/components/embed-event/event-types/embedded-torrent-comment.tsx b/src/components/embed-event/event-types/embedded-torrent-comment.tsx index 377482934..d70d8decf 100644 --- a/src/components/embed-event/event-types/embedded-torrent-comment.tsx +++ b/src/components/embed-event/event-types/embedded-torrent-comment.tsx @@ -9,7 +9,7 @@ import appSettings from "../../../services/settings/app-settings"; import EventVerificationIcon from "../../event-verification-icon"; import { TrustProvider } from "../../../providers/trust"; import Timestamp from "../../timestamp"; -import { getNeventCodeWithRelays } from "../../../helpers/nip19"; +import { getNeventForEventId } from "../../../helpers/nip19"; import { CompactNoteContent } from "../../compact-note-content"; import HoverLinkOverlay from "../../hover-link-overlay"; import { getReferences } from "../../../helpers/nostr/events"; @@ -26,7 +26,7 @@ export default function EmbeddedTorrentComment({ const { showSignatureVerification } = useSubject(appSettings); const refs = getReferences(comment); const torrent = useSingleEvent(refs.rootId, refs.rootRelay ? [refs.rootRelay] : []); - const linkToTorrent = refs.rootId && `/torrents/${getNeventCodeWithRelays(refs.rootId)}`; + const linkToTorrent = refs.rootId && `/torrents/${getNeventForEventId(refs.rootId)}`; const handleClick = useCallback( (e) => { diff --git a/src/components/embed-event/event-types/embedded-torrent.tsx b/src/components/embed-event/event-types/embedded-torrent.tsx index 5bb167353..5a3f1fc20 100644 --- a/src/components/embed-event/event-types/embedded-torrent.tsx +++ b/src/components/embed-event/event-types/embedded-torrent.tsx @@ -16,7 +16,7 @@ import { } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; -import { getNeventCodeWithRelays } from "../../../helpers/nip19"; +import { getSharableEventAddress } from "../../../helpers/nip19"; import UserAvatarLink from "../../user-avatar-link"; import { UserLink } from "../../user-link"; import { NostrEvent } from "../../../types/nostr-event"; @@ -29,7 +29,7 @@ import HoverLinkOverlay from "../../hover-link-overlay"; export default function EmbeddedTorrent({ torrent, ...props }: Omit & { torrent: NostrEvent }) { const navigate = useNavigateInDrawer(); - const link = `/torrents/${getNeventCodeWithRelays(torrent.id)}`; + const link = `/torrents/${getSharableEventAddress(torrent)}`; const handleClick = useCallback( (e) => { diff --git a/src/components/event-zap-modal/index.tsx b/src/components/event-zap-modal/index.tsx index 06da19274..368dce44e 100644 --- a/src/components/event-zap-modal/index.tsx +++ b/src/components/event-zap-modal/index.tsx @@ -29,6 +29,7 @@ import accountService from "../../services/account"; import PayStep from "./pay-step"; import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl"; import { UserLink } from "../user-link"; +import relayHintService from "../../services/event-relay-hint"; export type PayRequest = { invoice?: string; pubkey: string; error?: any }; @@ -69,7 +70,7 @@ async function getPayRequestForPubkey( .map((r) => r.url) ?? [], ) .slice(0, 4); - const eventRelays = event ? relayScoreboardService.getRankedRelays(getEventRelays(event.id).value).slice(0, 4) : []; + const eventRelays = event ? relayHintService.getEventRelayHints(event, 4) : []; const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4); const additional = relayScoreboardService.getRankedRelays(additionalRelays); diff --git a/src/components/note-link.tsx b/src/components/note-link.tsx index 78e65d3a5..3da4fdf89 100644 --- a/src/components/note-link.tsx +++ b/src/components/note-link.tsx @@ -3,14 +3,14 @@ import { Link, LinkProps } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; import { truncatedId } from "../helpers/nostr/events"; -import { getNeventCodeWithRelays } from "../helpers/nip19"; +import { getNeventForEventId } from "../helpers/nip19"; export type NoteLinkProps = LinkProps & { noteId: string; }; export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: NoteLinkProps) => { - const nevent = useMemo(() => getNeventCodeWithRelays(noteId), [noteId]); + const nevent = useMemo(() => getNeventForEventId(noteId), [noteId]); return ( diff --git a/src/components/note/components/repost-modal.tsx b/src/components/note/components/repost-modal.tsx index f4712464e..04fafb358 100644 --- a/src/components/note/components/repost-modal.tsx +++ b/src/components/note/components/repost-modal.tsx @@ -13,28 +13,25 @@ import { useDisclosure, useToast, } from "@chakra-ui/react"; +import { Kind } from "nostr-tools"; +import dayjs from "dayjs"; +import type { AddressPointer } from "nostr-tools/lib/types/nip19"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { EmbedEvent } from "../../embed-event"; -import { getEventRelays } from "../../../services/event-relays"; -import relayScoreboardService from "../../../services/relay-scoreboard"; -import { Kind } from "nostr-tools"; -import dayjs from "dayjs"; import NostrPublishAction from "../../../classes/nostr-publish-action"; import clientRelaysService from "../../../services/client-relays"; import { useSigningContext } from "../../../providers/signing-provider"; import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from "../../icons"; import useUserCommunitiesList from "../../../hooks/use-user-communities-list"; import useCurrentAccount from "../../../hooks/use-current-account"; -import { AddressPointer } from "nostr-tools/lib/types/nip19"; import { createCoordinate } from "../../../services/replaceable-event-requester"; +import relayHintService from "../../../services/event-relay-hint"; function buildRepost(event: NostrEvent): DraftNostrEvent { - const relays = getEventRelays(event.id).value; - const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? ""; - + const hint = relayHintService.getEventRelayHint(event); const tags: NostrEvent["tags"] = []; - tags.push(["e", event.id, topRelay]); + tags.push(["e", event.id, hint ?? ""]); return { kind: Kind.Repost, @@ -65,6 +62,7 @@ export default function RepostModal({ draftRepost.tags.push([ "a", createCoordinate(communityPointer.kind, communityPointer.pubkey, communityPointer.identifier), + relayHintService.getAddressPointerRelayHint(communityPointer) ?? "", ]); } const signed = await requestSignature(draftRepost); diff --git a/src/helpers/nip19.ts b/src/helpers/nip19.ts index f35be9687..073687abb 100644 --- a/src/helpers/nip19.ts +++ b/src/helpers/nip19.ts @@ -1,10 +1,9 @@ import { bech32 } from "bech32"; import { getPublicKey, nip19 } from "nostr-tools"; -import { getEventRelays } from "../services/event-relays"; -import relayScoreboardService from "../services/relay-scoreboard"; import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event"; -import { getEventUID, isReplaceable } from "./nostr/events"; +import { isReplaceable } from "./nostr/events"; import { DecodeResult } from "nostr-tools/lib/types/nip19"; +import relayHintService from "../services/event-relay-hint"; export function isHexKey(key?: string) { if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true; @@ -47,7 +46,7 @@ export function safeDecode(str: string) { } catch (e) {} } -export function getPubkey(result?: nip19.DecodeResult) { +export function getPubkeyFromDecodeResult(result?: nip19.DecodeResult) { if (!result) return; switch (result.type) { case "naddr": @@ -68,26 +67,21 @@ export function normalizeToHex(hex: string) { } export function getSharableEventAddress(event: NostrEvent) { - const relays = getEventRelays(getEventUID(event)).value; - const ranked = relayScoreboardService.getRankedRelays(relays); - const maxTwo = ranked.slice(0, 2); + const relays = relayHintService.getEventRelayHints(event, 2); if (isReplaceable(event.kind)) { const d = event.tags.find(isDTag)?.[1]; if (!d) return null; - return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays: maxTwo }); + return nip19.naddrEncode({ kind: event.kind, identifier: d, pubkey: event.pubkey, relays }); } else { - if (maxTwo.length == 2) { - return nip19.neventEncode({ id: event.id, kind: event.kind, relays: maxTwo }); - } else return nip19.neventEncode({ id: event.id, kind: event.kind, relays: maxTwo, author: event.pubkey }); + return nip19.neventEncode({ id: event.id, kind: event.kind, relays, author: event.pubkey }); } } -export function getNeventCodeWithRelays(eventId: string) { - const relays = getEventRelays(eventId).value; - const ranked = relayScoreboardService.getRankedRelays(relays); - const maxTwo = ranked.slice(0, 2); - return nip19.neventEncode({ id: eventId, relays: maxTwo }); +/** @deprecated use getSharableEventAddress unless required */ +export function getNeventForEventId(eventId: string, maxRelays = 2) { + const relays = relayHintService.getEventPointerRelayHints(eventId).slice(0, maxRelays); + return nip19.neventEncode({ id: eventId, relays }); } export function encodePointer(pointer: DecodeResult) { diff --git a/src/helpers/nostr/events.ts b/src/helpers/nostr/events.ts index 982d5c585..a7dd4aac5 100644 --- a/src/helpers/nostr/events.ts +++ b/src/helpers/nostr/events.ts @@ -193,30 +193,6 @@ export function parseCoordinate(a: string, requireD = false): CustomEventPointer }; } -export function draftAddCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string, relay?: string) { - if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("event already in list"); - - const draft: DraftNostrEvent = { - created_at: dayjs().unix(), - kind: list.kind, - content: list.content, - tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]], - }; - - return draft; -} - -export function draftRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string) { - const draft: DraftNostrEvent = { - created_at: dayjs().unix(), - kind: list.kind, - content: list.content, - tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)), - }; - - return draft; -} - export function parseHardcodedNoteContent(event: NostrEvent) { const json = safeJson(event.content, null); if (!json) return null; diff --git a/src/helpers/nostr/filter.ts b/src/helpers/nostr/filter.ts index 36c9c4ed2..11492b3e6 100644 --- a/src/helpers/nostr/filter.ts +++ b/src/helpers/nostr/filter.ts @@ -1,5 +1,6 @@ import stringify from "json-stringify-deterministic"; import { NostrQuery, NostrRequestFilter, RelayQueryMap } from "../../types/nostr-query"; +import localCacheRelayService, { LOCAL_CACHE_RELAY } from "../../services/local-cache-relay"; export function addQueryToFilter(filter: NostrRequestFilter, query: NostrQuery) { if (Array.isArray(filter)) { @@ -20,6 +21,13 @@ export function mapQueryMap(queryMap: RelayQueryMap, fn: (filter: NostrRequestFi export function createSimpleQueryMap(relays: string[], filter: NostrRequestFilter) { const map: RelayQueryMap = {}; + + // if the local cache relay is enabled, also ask it + if (localCacheRelayService.enabled) { + map[LOCAL_CACHE_RELAY] = filter; + } + for (const relay of relays) map[relay] = filter; + return map; } diff --git a/src/helpers/nostr/lists.ts b/src/helpers/nostr/lists.ts index 034f9dc21..6cdc371c0 100644 --- a/src/helpers/nostr/lists.ts +++ b/src/helpers/nostr/lists.ts @@ -149,7 +149,8 @@ export function listAddCoordinate( coordinate: string, relay?: string, ): DraftNostrEvent { - if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("coordinate already in list"); + if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("Event already in list"); + return { created_at: dayjs().unix(), kind: list.kind, diff --git a/src/helpers/nostr/post.ts b/src/helpers/nostr/post.ts index 18e82093d..486c3e8b9 100644 --- a/src/helpers/nostr/post.ts +++ b/src/helpers/nostr/post.ts @@ -1,12 +1,11 @@ import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event"; import { getMatchEmoji, getMatchHashtag } from "../regexp"; import { getReferences } from "./events"; -import { getEventRelays } from "../../services/event-relays"; -import relayScoreboardService from "../../services/relay-scoreboard"; -import { getPubkey, safeDecode } from "../nip19"; +import { getPubkeyFromDecodeResult, safeDecode } from "../nip19"; import { Emoji } from "../../providers/emoji-provider"; import { EventSplit } from "./zaps"; import { unique } from "../array"; +import relayHintService from "../../services/event-relay-hint"; function addTag(tags: Tag[], tag: Tag, overwrite = false) { if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) { @@ -21,10 +20,9 @@ function addTag(tags: Tag[], tag: Tag, overwrite = false) { return [...tags, tag]; } function AddEtag(tags: Tag[], eventId: string, type?: string, overwrite = false) { - const relays = getEventRelays(eventId).value ?? []; - const top = relayScoreboardService.getRankedRelays(relays)[0] ?? ""; + const hint = relayHintService.getEventPointerRelayHint(eventId) ?? ""; - const tag = type ? ["e", eventId, top, type] : ["e", eventId, top]; + const tag = type ? ["e", eventId, hint, type] : ["e", eventId, hint]; if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1] && t[3] === tag[3])) { if (overwrite) { @@ -73,7 +71,7 @@ export function getContentMentions(content: string) { Array.from(matched) .map((m) => { const parsed = safeDecode(m[1]); - return parsed && getPubkey(parsed); + return parsed && getPubkeyFromDecodeResult(parsed); }) .filter(Boolean) as string[], ); diff --git a/src/hooks/use-ranked-relay-configs.ts b/src/hooks/use-ranked-relay-configs.ts deleted file mode 100644 index e9e3e1764..000000000 --- a/src/hooks/use-ranked-relay-configs.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useMemo } from "react"; -import { RelayConfig } from "../classes/relay"; -import relayScoreboardService from "../services/relay-scoreboard"; - -export default function useRankedRelayConfigs(relays: RelayConfig[]) { - return useMemo(() => { - const rankedUrls = relayScoreboardService.getRankedRelays(relays.map((r) => r.url)); - return rankedUrls.map((u) => relays.find((r) => r.url === u) as RelayConfig); - }, [relays.join("|")]); -} diff --git a/src/hooks/use-shareable-profile-id.ts b/src/hooks/use-shareable-profile-id.ts index 759fcb910..2aa6949cd 100644 --- a/src/hooks/use-shareable-profile-id.ts +++ b/src/hooks/use-shareable-profile-id.ts @@ -1,7 +1,8 @@ import { useMemo } from "react"; -import relayScoreboardService from "../services/relay-scoreboard"; -import { RelayMode } from "../classes/relay"; import { nip19 } from "nostr-tools"; + +import { RelayMode } from "../classes/relay"; +import relayScoreboardService from "../services/relay-scoreboard"; import { useUserRelays } from "./use-user-relays"; export function useSharableProfileId(pubkey: string, relayCount = 2) { diff --git a/src/index.tsx b/src/index.tsx index d43de3427..c1f892e40 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,7 @@ import "./polyfill"; import { createRoot } from "react-dom/client"; import { App } from "./app"; import { GlobalProviders } from "./providers"; - -import "./services/serial-port"; +import "./services/local-cache-relay"; // setup dayjs import dayjs from "dayjs"; diff --git a/src/services/channel-metadata.ts b/src/services/channel-metadata.ts index bf16c5686..f205e43dc 100644 --- a/src/services/channel-metadata.ts +++ b/src/services/channel-metadata.ts @@ -12,6 +12,7 @@ import { logger } from "../helpers/debug"; import db from "./db"; import createDefer, { Deferred } from "../classes/deferred"; import { getChannelPointer } from "../helpers/nostr/channel"; +import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay"; type Pubkey = string; type Relay = string; @@ -25,6 +26,8 @@ export type RequestOptions = { // keepAlive?: boolean; }; +const RELAY_REQUEST_BATCH_TIME = 1000; + /** This class is ued to batch requests to a single relay */ class ChannelMetadataRelayLoader { private subscription: NostrSubscription; @@ -78,7 +81,7 @@ class ChannelMetadataRelayLoader { return subject; } - updateThrottle = _throttle(this.update, 1000); + updateThrottle = _throttle(this.update, RELAY_REQUEST_BATCH_TIME); update() { let needsUpdate = false; for (const channelId of this.requestNext) { @@ -119,6 +122,9 @@ class ChannelMetadataRelayLoader { } } +const READ_CACHE_BATCH_TIME = 250; +const WRITE_CACHE_BATCH_TIME = 250; + /** This is a clone of ReplaceableEventLoaderService to support channel metadata */ class ChannelMetadataService { private metadata = new SuperMap>(() => new Subject()); @@ -147,7 +153,7 @@ class ChannelMetadataService { } private readFromCachePromises = new Map>(); - private readFromCacheThrottle = _throttle(this.readFromCache, 1000); + private readFromCacheThrottle = _throttle(this.readFromCache, READ_CACHE_BATCH_TIME); private async readFromCache() { if (this.readFromCachePromises.size === 0) return; @@ -187,7 +193,7 @@ class ChannelMetadataService { } private writeCacheQueue = new Map(); - private writeToCacheThrottle = _throttle(this.writeToCache, 1000); + private writeToCacheThrottle = _throttle(this.writeToCache, WRITE_CACHE_BATCH_TIME); private async writeToCache() { if (this.writeCacheQueue.size === 0) return; @@ -224,7 +230,11 @@ class ChannelMetadataService { private requestChannelMetadataFromRelays(relays: string[], channelId: string) { const sub = this.metadata.get(channelId); - for (const relay of relays) { + const relayUrls = Array.from(relays); + if (localCacheRelayService.enabled) { + relayUrls.unshift(LOCAL_CACHE_RELAY); + } + for (const relay of relayUrls) { const request = this.loaders.get(relay).requestMetadata(channelId); sub.connectWithHandler(request, (event, next, current) => { diff --git a/src/services/event-exists.ts b/src/services/event-exists.ts index f21cb9b4b..b855c9736 100644 --- a/src/services/event-exists.ts +++ b/src/services/event-exists.ts @@ -8,6 +8,7 @@ import relayScoreboardService from "./relay-scoreboard"; import { logger } from "../helpers/debug"; import { matchFilter, matchFilters } from "nostr-tools"; import { NostrEvent } from "../types/nostr-event"; +import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay"; function hashFilter(filter: NostrRequestFilter) { // const encoder = new TextEncoder(); @@ -41,7 +42,10 @@ class EventExistsService { if (!this.filters.has(key)) this.filters.set(key, filter); if (sub.value !== true) { - for (const url of relays) { + const relayUrls = Array.from(relays); + if (localCacheRelayService.enabled) relayUrls.unshift(LOCAL_CACHE_RELAY); + + for (const url of relayUrls) { if (!asked.has(url) && !pending.has(url)) { pending.add(url); } diff --git a/src/services/event-relay-hint.ts b/src/services/event-relay-hint.ts new file mode 100644 index 000000000..dde8407b2 --- /dev/null +++ b/src/services/event-relay-hint.ts @@ -0,0 +1,49 @@ +import { NostrEvent } from "../types/nostr-event"; +import { getEventRelays } from "./event-relays"; +import relayScoreboardService from "./relay-scoreboard"; +import type { AddressPointer, EventPointer } from "nostr-tools/lib/types/nip19"; +import { createCoordinate } from "./replaceable-event-requester"; + +function pickBestRelays(relays: string[]) { + // ignore local relays + relays = relays.filter((url) => !url.includes("://localhost") && !url.includes("://192.168")); + + return relayScoreboardService.getRankedRelays(relays); +} + +function getAddressPointerRelayHint(pointer: AddressPointer): string | undefined { + let relays = getEventRelays(createCoordinate(pointer.kind, pointer.pubkey, pointer.identifier)).value; + return pickBestRelays(relays)[0]; +} + +function getEventPointerRelayHints(pointerOrId: string | EventPointer): string[] { + let relays = + typeof pointerOrId === "string" ? getEventRelays(pointerOrId).value : getEventRelays(pointerOrId.id).value; + return pickBestRelays(relays); +} +function getEventPointerRelayHint(pointerOrId: string | EventPointer): string | undefined { + return getEventPointerRelayHints(pointerOrId)[0]; +} + +function getEventRelayHint(event: NostrEvent): string | undefined { + return getEventRelayHints(event, 1)[0]; +} + +function getEventRelayHints(event: NostrEvent, count = 2): string[] { + // NOTE: in the future try to use the events authors relays + + let relays = getEventRelays(event.id).value; + + return pickBestRelays(relays).slice(0, count); +} + +const relayHintService = { + getEventRelayHints, + getEventRelayHint, + getEventPointerRelayHint, + getEventPointerRelayHints, + getAddressPointerRelayHint, + pickBestRelays, +}; + +export default relayHintService; diff --git a/src/services/local-cache-relay.ts b/src/services/local-cache-relay.ts new file mode 100644 index 000000000..df69a5ca0 --- /dev/null +++ b/src/services/local-cache-relay.ts @@ -0,0 +1,62 @@ +import { logger } from "../helpers/debug"; +import { NostrEvent } from "../types/nostr-event"; +import relayPoolService from "./relay-pool"; +import _throttle from "lodash.throttle"; + +const enabled = !!localStorage.getItem("enable-cache-relay"); +export const LOCAL_CACHE_RELAY = "ws://localhost:7000"; + +const wroteEvents = new Set(); +const writeQueue: NostrEvent[] = []; + +const BATCH_WRITE = 100; + +const log = logger.extend(`LocalCacheRelay`); +async function flush() { + for (let i = 0; i < BATCH_WRITE; i++) { + const e = writeQueue.pop(); + if (!e) continue; + relayPoolService.requestRelay(LOCAL_CACHE_RELAY).send(["EVENT", e]); + } +} +function report() { + if (writeQueue.length) { + log(`${writeQueue.length} events in write queue`); + } +} + +function addToQueue(e: NostrEvent) { + if (!enabled) return; + if (!wroteEvents.has(e.id)) { + wroteEvents.add(e.id); + writeQueue.push(e); + } +} + +if (enabled) { + log("Enabled"); + relayPoolService.onRelayCreated.subscribe((relay) => { + if (relay.url !== LOCAL_CACHE_RELAY) { + relay.onEvent.subscribe((incomingEvent) => addToQueue(incomingEvent.body)); + } + }); +} + +const localCacheRelayService = { + enabled, + addToQueue, +}; + +setInterval(() => { + if (enabled) flush(); +}, 1000); +setInterval(() => { + if (enabled) report(); +}, 1000 * 10); + +if (import.meta.env.DEV) { + //@ts-ignore + window.localCacheRelayService = localCacheRelayService; +} + +export default localCacheRelayService; diff --git a/src/services/replaceable-event-requester.ts b/src/services/replaceable-event-requester.ts index 029fb9c79..83498cf55 100644 --- a/src/services/replaceable-event-requester.ts +++ b/src/services/replaceable-event-requester.ts @@ -12,6 +12,7 @@ import db from "./db"; import { nameOrPubkey } from "./user-metadata"; import { getEventCoordinate } from "../helpers/nostr/events"; import createDefer, { Deferred } from "../classes/deferred"; +import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay"; type Pubkey = string; type Relay = string; @@ -32,6 +33,8 @@ export function createCoordinate(kind: number, pubkey: string, d?: string) { return `${kind}:${pubkey}${d ? ":" + d : ""}`; } +const RELAY_REQUEST_BATCH_TIME = 1000; + /** This class is ued to batch requests to a single relay */ class ReplaceableEventRelayLoader { private subscription: NostrSubscription; @@ -85,7 +88,7 @@ class ReplaceableEventRelayLoader { return event; } - updateThrottle = _throttle(this.update, 1000); + updateThrottle = _throttle(this.update, RELAY_REQUEST_BATCH_TIME); update() { let needsUpdate = false; for (const cord of this.requestNext) { @@ -144,6 +147,9 @@ class ReplaceableEventRelayLoader { } } +const READ_CACHE_BATCH_TIME = 250; +const WRITE_CACHE_BATCH_TIME = 250; + class ReplaceableEventLoaderService { private events = new SuperMap>(() => new Subject()); @@ -170,7 +176,7 @@ class ReplaceableEventLoaderService { } private readFromCachePromises = new Map>(); - private readFromCacheThrottle = _throttle(this.readFromCache, 1000); + private readFromCacheThrottle = _throttle(this.readFromCache, READ_CACHE_BATCH_TIME); private async readFromCache() { if (this.readFromCachePromises.size === 0) return; @@ -210,7 +216,7 @@ class ReplaceableEventLoaderService { } private writeCacheQueue = new Map(); - private writeToCacheThrottle = _throttle(this.writeToCache, 1000); + private writeToCacheThrottle = _throttle(this.writeToCache, WRITE_CACHE_BATCH_TIME); private async writeToCache() { if (this.writeCacheQueue.size === 0) return; @@ -248,7 +254,10 @@ class ReplaceableEventLoaderService { const cord = createCoordinate(kind, pubkey, d); const sub = this.events.get(cord); - for (const relay of relays) { + const relayUrls = Array.from(relays); + if (localCacheRelayService.enabled) relayUrls.unshift(LOCAL_CACHE_RELAY); + + for (const relay of relayUrls) { const request = this.loaders.get(relay).requestEvent(kind, pubkey, d); sub.connectWithHandler(request, (event, next, current) => { diff --git a/src/services/single-event.ts b/src/services/single-event.ts index 8c7067290..0ee8f4bbb 100644 --- a/src/services/single-event.ts +++ b/src/services/single-event.ts @@ -5,6 +5,9 @@ import Subject from "../classes/subject"; import SuperMap from "../classes/super-map"; import { safeRelayUrls } from "../helpers/url"; import { NostrEvent } from "../types/nostr-event"; +import localCacheRelayService, { LOCAL_CACHE_RELAY } from "./local-cache-relay"; + +const RELAY_REQUEST_BATCH_TIME = 1000; class SingleEventService { private cache = new SuperMap>(() => new Subject()); @@ -14,7 +17,9 @@ class SingleEventService { const subject = this.cache.get(id); if (subject.value) return subject; - this.pending.set(id, this.pending.get(id)?.concat(safeRelayUrls(relays)) ?? safeRelayUrls(relays)); + const newUrls = safeRelayUrls(relays); + if (localCacheRelayService.enabled) newUrls.push(LOCAL_CACHE_RELAY); + this.pending.set(id, this.pending.get(id)?.concat(newUrls) ?? newUrls); this.batchRequestsThrottle(); return subject; @@ -24,7 +29,7 @@ class SingleEventService { this.cache.get(event.id).next(event); } - private batchRequestsThrottle = _throttle(this.batchRequests, 1000 * 2); + private batchRequestsThrottle = _throttle(this.batchRequests, RELAY_REQUEST_BATCH_TIME); batchRequests() { if (this.pending.size === 0) return; diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts index 1880c9963..a9a079b32 100644 --- a/src/services/user-metadata.ts +++ b/src/services/user-metadata.ts @@ -8,6 +8,8 @@ import SuperMap from "../classes/super-map"; import Subject from "../classes/subject"; import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester"; +const WRITE_USER_SEARCH_BATCH_TIME = 500; + class UserMetadataService { private parsedSubjects = new SuperMap>((pubkey) => { const sub = new Subject(); @@ -34,7 +36,7 @@ class UserMetadataService { } private writeSearchQueue = new Set(); - private writeSearchDataThrottle = _throttle(this.writeSearchData.bind(this)); + private writeSearchDataThrottle = _throttle(this.writeSearchData.bind(this), WRITE_USER_SEARCH_BATCH_TIME); private async writeSearchData() { if (this.writeSearchQueue.size === 0) return; diff --git a/src/views/community/index.tsx b/src/views/community/index.tsx index 9484390ab..7cde62f51 100644 --- a/src/views/community/index.tsx +++ b/src/views/community/index.tsx @@ -3,7 +3,7 @@ import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities"; import useReplaceableEvent from "../../hooks/use-replaceable-event"; import { Spinner } from "@chakra-ui/react"; import CommunityHomePage from "./community-home"; -import { getPubkey, isHexKey, safeDecode } from "../../helpers/nip19"; +import { getPubkeyFromDecodeResult, isHexKey, safeDecode } from "../../helpers/nip19"; function useCommunityPointer() { const { community, pubkey } = useParams(); @@ -12,7 +12,7 @@ function useCommunityPointer() { if (decoded) { if (decoded.type === "naddr" && decoded.data.kind === COMMUNITY_DEFINITION_KIND) return decoded.data; } else if (community && pubkey) { - const hexPubkey = isHexKey(pubkey) ? pubkey : getPubkey(safeDecode(pubkey)); + const hexPubkey = isHexKey(pubkey) ? pubkey : getPubkeyFromDecodeResult(safeDecode(pubkey)); if (!hexPubkey) return; return { kind: COMMUNITY_DEFINITION_KIND, pubkey: hexPubkey, identifier: community }; diff --git a/src/views/emoji-packs/components/emoji-pack-favorite-button.tsx b/src/views/emoji-packs/components/emoji-pack-favorite-button.tsx index 6f5010dcb..f1a7eda01 100644 --- a/src/views/emoji-packs/components/emoji-pack-favorite-button.tsx +++ b/src/views/emoji-packs/components/emoji-pack-favorite-button.tsx @@ -4,13 +4,14 @@ import dayjs from "dayjs"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { StarEmptyIcon, StarFullIcon } from "../../../components/icons"; -import { draftAddCoordinate, draftRemoveCoordinate, getEventCoordinate } from "../../../helpers/nostr/events"; +import { getEventCoordinate } from "../../../helpers/nostr/events"; import { useSigningContext } from "../../../providers/signing-provider"; import NostrPublishAction from "../../../classes/nostr-publish-action"; import clientRelaysService from "../../../services/client-relays"; import replaceableEventLoaderService from "../../../services/replaceable-event-requester"; import { USER_EMOJI_LIST_KIND } from "../../../helpers/nostr/emoji-packs"; import useFavoriteEmojiPacks from "../../../hooks/use-favorite-emoji-packs"; +import { listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists"; export default function EmojiPackFavoriteButton({ pack, @@ -33,7 +34,7 @@ export default function EmojiPackFavoriteButton({ try { setLoading(true); - const draft = isFavorite ? draftRemoveCoordinate(prev, coordinate) : draftAddCoordinate(prev, coordinate); + const draft = isFavorite ? listRemoveCoordinate(prev, coordinate) : listAddCoordinate(prev, coordinate); const signed = await requestSignature(draft); const pub = new NostrPublishAction( isFavorite ? "Unfavorite Emoji pack" : "Favorite emoji pack", diff --git a/src/views/lists/components/list-favorite-button.tsx b/src/views/lists/components/list-favorite-button.tsx index 351fb440e..f9c7115e0 100644 --- a/src/views/lists/components/list-favorite-button.tsx +++ b/src/views/lists/components/list-favorite-button.tsx @@ -4,13 +4,13 @@ import dayjs from "dayjs"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { StarEmptyIcon, StarFullIcon } from "../../../components/icons"; -import { draftAddCoordinate, draftRemoveCoordinate, getEventCoordinate } from "../../../helpers/nostr/events"; +import { getEventCoordinate } from "../../../helpers/nostr/events"; import { useSigningContext } from "../../../providers/signing-provider"; import NostrPublishAction from "../../../classes/nostr-publish-action"; import clientRelaysService from "../../../services/client-relays"; import replaceableEventLoaderService from "../../../services/replaceable-event-requester"; import useFavoriteLists, { FAVORITE_LISTS_IDENTIFIER } from "../../../hooks/use-favorite-lists"; -import { NOTE_LIST_KIND, isSpecialListKind } from "../../../helpers/nostr/lists"; +import { NOTE_LIST_KIND, isSpecialListKind, listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists"; export default function ListFavoriteButton({ list, @@ -38,7 +38,7 @@ export default function ListFavoriteButton({ try { setLoading(true); - const draft = isFavorite ? draftRemoveCoordinate(prev, coordinate) : draftAddCoordinate(prev, coordinate); + const draft = isFavorite ? listRemoveCoordinate(prev, coordinate) : listAddCoordinate(prev, coordinate); const signed = await requestSignature(draft); const pub = new NostrPublishAction("Favorite list", clientRelaysService.getWriteUrls(), signed); replaceableEventLoaderService.handleEvent(signed); diff --git a/src/views/note/components/thread-post.tsx b/src/views/note/components/thread-post.tsx index bdb5ad873..716278be5 100644 --- a/src/views/note/components/thread-post.tsx +++ b/src/views/note/components/thread-post.tsx @@ -36,7 +36,7 @@ import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon" import NoteProxyLink from "../../../components/note/components/note-proxy-link"; import { NoteDetailsButton } from "../../../components/note/components/note-details-button"; import EventInteractionDetailsModal from "../../../components/event-interactions-modal"; -import { getNeventCodeWithRelays } from "../../../helpers/nip19"; +import { getSharableEventAddress } from "../../../helpers/nip19"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import useAppSettings from "../../../hooks/use-app-settings"; import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props"; @@ -80,7 +80,7 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }: - + {replies.length > 0 ? ( diff --git a/src/views/settings/performance-settings.tsx b/src/views/settings/performance-settings.tsx index f1195d914..38f83d50a 100644 --- a/src/views/settings/performance-settings.tsx +++ b/src/views/settings/performance-settings.tsx @@ -13,13 +13,28 @@ import { Input, Link, FormErrorMessage, + Code, + Button, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + useDisclosure, + Text, + Heading, } from "@chakra-ui/react"; import { safeUrl } from "../../helpers/parse"; import { AppSettings } from "../../services/settings/migrations"; import { PerformanceIcon } from "../../components/icons"; +import { useLocalStorage } from "react-use"; +import { LOCAL_CACHE_RELAY } from "../../services/local-cache-relay"; export default function PerformanceSettings() { const { register, formState } = useFormContext(); + const [localCacheRelay, setLocalCacheRelay] = useLocalStorage("enable-cache-relay"); + const cacheDetails = useDisclosure(); return ( @@ -95,6 +110,58 @@ export default function PerformanceSettings() { Enabled: show signature verification on notes + + + + Local Cache Relay + + setLocalCacheRelay(e.target.checked)} + /> + + + Enabled: Use a local relay as a caching service + + + + + Local cache relay + + + + When this option is enabled noStrudel will mirror every event it sees to the relay. It will also try + to load as much data from the relay first before reaching out to other relays. + + + For security reasons noStrudel will only use ws://localhost:7000 as the cache relay. + + + Linux setup instructions + + + You can run a local relay using{" "} + + docker + {" "} + and{" "} + + nostr-rs-relay + + + 1. Create a folder for the data + mkdir ~/.nostr-relay/data -p -m 777 + 2. Start the relay + + docker run --rm -it -p 7000:8080 -v ~/.nostr-relay/data:/usr/src/app/db scsibug/nostr-rs-relay + + + + + diff --git a/src/views/torrents/components/torrent-table-row.tsx b/src/views/torrents/components/torrent-table-row.tsx index 292a016e8..aeab3ce4f 100644 --- a/src/views/torrents/components/torrent-table-row.tsx +++ b/src/views/torrents/components/torrent-table-row.tsx @@ -7,7 +7,7 @@ import { NostrEvent } from "../../../types/nostr-event"; import Timestamp from "../../../components/timestamp"; import { UserLink } from "../../../components/user-link"; import Magnet from "../../../components/icons/magnet"; -import { getNeventCodeWithRelays } from "../../../helpers/nip19"; +import { getNeventForEventId } from "../../../helpers/nip19"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { getEventUID } from "../../../helpers/nostr/events"; import { formatBytes } from "../../../helpers/number"; @@ -58,7 +58,7 @@ function TorrentTableRow({ torrent }: { torrent: NostrEvent }) { ))} - + {getTorrentTitle(torrent)}