From c606193c823911b6ba902a772c2b11514aa7752f Mon Sep 17 00:00:00 2001 From: Tristan Edwards Date: Sun, 1 Jan 2023 22:34:44 +0100 Subject: [PATCH] Add new useProfile hook It should batch requests when possible to prevent the "Max connections open" error --- package.json | 1 + src/core.tsx | 225 ++++++++++++++++++++++++++++++++++++++++++++ src/index.tsx | 228 +-------------------------------------------- src/useProfile.tsx | 111 ++++++++++++++++++++++ src/utils.ts | 4 + yarn.lock | 5 + 6 files changed, 349 insertions(+), 225 deletions(-) create mode 100644 src/core.tsx create mode 100644 src/useProfile.tsx diff --git a/package.json b/package.json index f2d4d25..810960a 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "typescript": "^4.9.4" }, "dependencies": { + "jotai": "^1.12.1", "nostr-tools": "^1.1.0" } } diff --git a/src/core.tsx b/src/core.tsx new file mode 100644 index 0000000..c5e98f6 --- /dev/null +++ b/src/core.tsx @@ -0,0 +1,225 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react" +import { Provider as JotaiProvider } from "jotai" + +import { Relay, Filter, Event as NostrEvent, relayInit, Sub } from "nostr-tools" + +import { uniqBy } from "./utils" + +type OnConnectFunc = (relay: Relay) => void +type OnDisconnectFunc = (relay: Relay) => void +type OnEventFunc = (event: NostrEvent) => void +type OnSubscribeFunc = (sub: Sub, relay: Relay) => void + +interface NostrContextType { + isLoading: boolean + debug?: boolean + connectedRelays: Relay[] + onConnect: (_onConnectCallback?: OnConnectFunc) => void + onDisconnect: (_onDisconnectCallback?: OnDisconnectFunc) => void + publish: (event: NostrEvent) => void +} + +const NostrContext = createContext({ + isLoading: true, + connectedRelays: [], + onConnect: () => null, + onDisconnect: () => null, + publish: () => null, +}) + +const log = ( + isOn: boolean | undefined, + type: "info" | "error" | "warn", + ...args: unknown[] +) => { + if (!isOn) return + console[type](...args) +} + +export function NostrProvider({ + children, + relayUrls, + debug, +}: { + children: ReactNode + relayUrls: string[] + debug?: boolean +}) { + const [isLoading, setIsLoading] = useState(true) + const [connectedRelays, setConnectedRelays] = useState([]) + + let onConnectCallback: null | OnConnectFunc = null + let onDisconnectCallback: null | OnDisconnectFunc = null + + const isFirstRender = useRef(true) + + const connectToRelays = useCallback(() => { + relayUrls.forEach(async (relayUrl) => { + const relay = relayInit(relayUrl) + relay.connect() + + relay.on("connect", () => { + log(debug, "info", `✅ nostr (${relayUrl}): Connected!`) + setIsLoading(false) + onConnectCallback?.(relay) + setConnectedRelays((prev) => uniqBy([...prev, relay], "url")) + }) + + relay.on("disconnect", () => { + log(debug, "warn", `🚪 nostr (${relayUrl}): Connection closed.`) + onDisconnectCallback?.(relay) + setConnectedRelays((prev) => prev.filter((r) => r.url !== relayUrl)) + }) + + relay.on("error", () => { + log(debug, "error", `❌ nostr (${relayUrl}): Connection error!`) + }) + }) + }, []) + + useEffect(() => { + // Make sure we only start the relays once (even in strict-mode) + if (isFirstRender.current) { + isFirstRender.current = false + connectToRelays() + } + }, []) + + const publish = (event: NostrEvent) => { + return connectedRelays.map((relay) => { + log(debug, "info", `⬆️ nostr (${relay.url}): Sending event:`, event) + + return relay.publish(event) + }) + } + + const value: NostrContextType = { + debug, + isLoading, + connectedRelays, + publish, + onConnect: (_onConnectCallback?: OnConnectFunc) => { + if (_onConnectCallback) { + onConnectCallback = _onConnectCallback + } + }, + onDisconnect: (_onDisconnectCallback?: OnDisconnectFunc) => { + if (_onDisconnectCallback) { + onDisconnectCallback = _onDisconnectCallback + } + }, + } + + return {children} +} + +export function useNostr() { + return useContext(NostrContext) +} + +export function useNostrEvents({ + filter, + enabled = true, +}: { + filter: Filter + enabled?: boolean +}) { + const { isLoading, onConnect, debug, connectedRelays } = useNostr() + const [events, setEvents] = useState([]) + const [unsubscribe, setUnsubscribe] = useState<() => void | void>(() => { + return + }) + + let onEventCallback: null | OnEventFunc = null + let onSubscribeCallback: null | OnSubscribeFunc = null + + // Lets us detect changes in the nested filter object for the useEffect hook + const filterBase64 = + typeof window !== "undefined" ? window.btoa(JSON.stringify(filter)) : null + + const _unsubscribe = (sub: Sub, relay: Relay) => { + log( + debug, + "info", + `🙉 nostr (${relay.url}): Unsubscribing from filter:`, + filter, + ) + return sub.unsub() + } + + const subscribe = useCallback((relay: Relay, filter: Filter) => { + log( + debug, + "info", + `👂 nostr (${relay.url}): Subscribing to filter:`, + filter, + ) + const sub = relay.sub([filter]) + + const unsubscribeFunc = () => { + _unsubscribe(sub, relay) + } + + setUnsubscribe(() => unsubscribeFunc) + + sub.on("event", (event: NostrEvent) => { + log(debug, "info", `⬇️ nostr (${relay.url}): Received event:`, event) + onEventCallback?.(event) + setEvents((_events) => { + return [event, ..._events] + }) + }) + + return sub + }, []) + + useEffect(() => { + if (!enabled) return + + const relaySubs = connectedRelays.map((relay) => { + const sub = subscribe(relay, filter) + + onSubscribeCallback?.(sub, relay) + + return { + sub, + relay, + } + }) + + return () => { + relaySubs.forEach(({ sub, relay }) => { + _unsubscribe(sub, relay) + }) + } + }, [connectedRelays, filterBase64, enabled]) + + const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [] + const sortedEvents = uniqEvents.sort((a, b) => b.created_at - a.created_at) + + return { + isLoading, + events: sortedEvents, + onConnect, + connectedRelays, + unsubscribe, + onSubscribe: (_onSubscribeCallback: OnSubscribeFunc) => { + if (_onSubscribeCallback) { + onSubscribeCallback = _onSubscribeCallback + } + }, + onEvent: (_onEventCallback: OnEventFunc) => { + if (_onEventCallback) { + onEventCallback = _onEventCallback + } + }, + } +} diff --git a/src/index.tsx b/src/index.tsx index 770cddf..aae72c4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,225 +1,3 @@ -import { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react" - -import { Relay, Filter, Event as NostrEvent, relayInit, Sub } from "nostr-tools" - -import { uniqBy } from "./utils" -export { dateToUnix } from "./utils" - -type OnConnectFunc = (relay: Relay) => void -type OnDisconnectFunc = (relay: Relay) => void -type OnEventFunc = (event: NostrEvent) => void -type OnSubscribeFunc = (sub: Sub, relay: Relay) => void - -interface NostrContextType { - isLoading: boolean - debug?: boolean - connectedRelays: Relay[] - onConnect: (_onConnectCallback?: OnConnectFunc) => void - onDisconnect: (_onDisconnectCallback?: OnDisconnectFunc) => void - publish: (event: NostrEvent) => void -} - -const NostrContext = createContext({ - isLoading: true, - connectedRelays: [], - onConnect: () => null, - onDisconnect: () => null, - publish: () => null, -}) - -const log = ( - isOn: boolean | undefined, - type: "info" | "error" | "warn", - ...args: unknown[] -) => { - if (!isOn) return - console[type](...args) -} - -export function NostrProvider({ - children, - relayUrls, - debug, -}: { - children: ReactNode - relayUrls: string[] - debug?: boolean -}) { - const [isLoading, setIsLoading] = useState(true) - const [connectedRelays, setConnectedRelays] = useState([]) - - let onConnectCallback: null | OnConnectFunc = null - let onDisconnectCallback: null | OnDisconnectFunc = null - - const isFirstRender = useRef(true) - - const connectToRelays = useCallback(() => { - relayUrls.forEach(async (relayUrl) => { - const relay = relayInit(relayUrl) - relay.connect() - - relay.on("connect", () => { - log(debug, "info", `✅ nostr (${relayUrl}): Connected!`) - setIsLoading(false) - onConnectCallback?.(relay) - setConnectedRelays((prev) => uniqBy([...prev, relay], "url")) - }) - - relay.on("disconnect", () => { - log(debug, "warn", `🚪 nostr (${relayUrl}): Connection closed.`) - onDisconnectCallback?.(relay) - setConnectedRelays((prev) => prev.filter((r) => r.url !== relayUrl)) - }) - - relay.on("error", () => { - log(debug, "error", `❌ nostr (${relayUrl}): Connection error!`) - }) - }) - }, []) - - useEffect(() => { - // Make sure we only start the relays once (even in strict-mode) - if (isFirstRender.current) { - isFirstRender.current = false - connectToRelays() - } - }, []) - - const publish = (event: NostrEvent) => { - return connectedRelays.map((relay) => { - log(debug, "info", `⬆️ nostr (${relay.url}): Sending event:`, event) - - return relay.publish(event) - }) - } - - const value: NostrContextType = { - debug, - isLoading, - connectedRelays, - publish, - onConnect: (_onConnectCallback?: OnConnectFunc) => { - if (_onConnectCallback) { - onConnectCallback = _onConnectCallback - } - }, - onDisconnect: (_onDisconnectCallback?: OnDisconnectFunc) => { - if (_onDisconnectCallback) { - onDisconnectCallback = _onDisconnectCallback - } - }, - } - - return {children} -} - -export function useNostr() { - return useContext(NostrContext) -} - -export function useNostrEvents({ - filter, - enabled = true, -}: { - filter: Filter - enabled?: boolean -}) { - const { isLoading, onConnect, debug, connectedRelays } = useNostr() - const [events, setEvents] = useState([]) - const [unsubscribe, setUnsubscribe] = useState<() => void | void>(() => { - return - }) - - let onEventCallback: null | OnEventFunc = null - let onSubscribeCallback: null | OnSubscribeFunc = null - - // Lets us detect changes in the nested filter object for the useEffect hook - const filterBase64 = - typeof window !== "undefined" ? window.btoa(JSON.stringify(filter)) : null - - const _unsubscribe = (sub: Sub, relay: Relay) => { - log( - debug, - "info", - `🙉 nostr (${relay.url}): Unsubscribing from filter:`, - filter, - ) - return sub.unsub() - } - - const subscribe = useCallback((relay: Relay, filter: Filter) => { - log( - debug, - "info", - `👂 nostr (${relay.url}): Subscribing to filter:`, - filter, - ) - const sub = relay.sub([filter]) - - const unsubscribeFunc = () => { - _unsubscribe(sub, relay) - } - - setUnsubscribe(() => unsubscribeFunc) - - sub.on("event", (event: NostrEvent) => { - log(debug, "info", `⬇️ nostr (${relay.url}): Received event:`, event) - onEventCallback?.(event) - setEvents((_events) => { - return [event, ..._events] - }) - }) - - return sub - }, []) - - useEffect(() => { - if (!enabled) return - - const relaySubs = connectedRelays.map((relay) => { - const sub = subscribe(relay, filter) - - onSubscribeCallback?.(sub, relay) - - return { - sub, - relay, - } - }) - - return () => { - relaySubs.forEach(({ sub, relay }) => { - _unsubscribe(sub, relay) - }) - } - }, [connectedRelays, filterBase64, enabled]) - - const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [] - const sortedEvents = uniqEvents.sort((a, b) => b.created_at - a.created_at) - - return { - isLoading, - events: sortedEvents, - onConnect, - connectedRelays, - unsubscribe, - onSubscribe: (_onSubscribeCallback: OnSubscribeFunc) => { - if (_onSubscribeCallback) { - onSubscribeCallback = _onSubscribeCallback - } - }, - onEvent: (_onEventCallback: OnEventFunc) => { - if (_onEventCallback) { - onEventCallback = _onEventCallback - } - }, - } -} +export * from "./utils" +export * from "./core" +export * from "./useProfile" diff --git a/src/useProfile.tsx b/src/useProfile.tsx new file mode 100644 index 0000000..23a774d --- /dev/null +++ b/src/useProfile.tsx @@ -0,0 +1,111 @@ +import { atom, useAtom } from "jotai" +import { nip19 } from "nostr-tools" +import { useEffect, useState } from "react" + +import { useNostrEvents } from "./core" +import { uniqValues } from "./utils" + +interface Metadata { + name?: string + display_name?: string + picture?: string + about?: string + website?: string + lud06?: string + lud16?: string + nip06?: string +} + +const QUEUE_DEBOUNCE_DURATION = 100 + +let timer: NodeJS.Timeout | undefined = undefined + +const queuedPubkeysAtom = atom([]) +const requestedPubkeysAtom = atom([]) +const fetchedProfilesAtom = atom>({}) + +function useProfileQueue({ pubkey }: { pubkey: string }) { + const [isReadyToFetch, setIsReadyToFetch] = useState(false) + + const [queuedPubkeys, setQueuedPubkeys] = useAtom(queuedPubkeysAtom) + + const [requestedPubkeys] = useAtom(requestedPubkeysAtom) + const alreadyRequested = !!requestedPubkeys.includes(pubkey) + + useEffect(() => { + if (alreadyRequested) { + return + } + + clearTimeout(timer) + + timer = setTimeout(() => { + setIsReadyToFetch(true) + }, QUEUE_DEBOUNCE_DURATION) + + setQueuedPubkeys((_pubkeys: string[]) => { + // Unique values only: + const arr = [..._pubkeys, pubkey].filter(uniqValues).filter((_pubkey) => { + return !requestedPubkeys.includes(_pubkey) + }) + + console.log("ARR", arr) + + return arr + }) + }, [pubkey, setQueuedPubkeys, alreadyRequested, requestedPubkeys]) + + return { + pubkeysToFetch: isReadyToFetch ? queuedPubkeys : [], + } +} + +export function useProfile({ pubkey }: { pubkey: string }) { + const [, setRequestedPubkeys] = useAtom(requestedPubkeysAtom) + const { pubkeysToFetch } = useProfileQueue({ pubkey }) + const enabled = !!pubkeysToFetch.length + + const [fetchedProfiles, setFetchedProfiles] = useAtom(fetchedProfilesAtom) + + const { onEvent, onSubscribe } = useNostrEvents({ + filter: { + kinds: [0], + authors: pubkeysToFetch, + }, + enabled, + }) + + onSubscribe(() => { + // Reset list + // (We've already opened a subscription to these pubkeys now) + setRequestedPubkeys((_pubkeys) => { + return [..._pubkeys, ...pubkeysToFetch].filter(uniqValues) + }) + }) + + onEvent((rawMetadata) => { + try { + const metadata: Metadata = JSON.parse(rawMetadata.content) + const metaPubkey = rawMetadata.pubkey + + if (metadata) { + setFetchedProfiles((_profiles: Record) => { + return { + ..._profiles, + [metaPubkey]: metadata, + } + }) + } + } catch (err) { + console.error(err, rawMetadata) + } + }) + + const metadata = fetchedProfiles[pubkey] + const npub = nip19.npubEncode(pubkey) + + return { + ...metadata, + npub, + } +} diff --git a/src/utils.ts b/src/utils.ts index d5ef3c0..9516465 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,6 +10,10 @@ export const uniqBy = (arr: T[], key: keyof T): T[] => { ) } +export const uniqValues = (value: string, index: number, self: string[]) => { + return self.indexOf(value) === index +} + export const dateToUnix = (_date?: Date) => { const date = _date || new Date() diff --git a/yarn.lock b/yarn.lock index 0a4bc96..f39d33e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4220,6 +4220,11 @@ jest@^27.4.7: import-local "^3.0.2" jest-cli "^27.5.1" +jotai@^1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.12.1.tgz#18808724c96f8c038f6ee8b75f3ac37e1ac35608" + integrity sha512-t6gsYM1WkQHMOazaZYLykCA+fh9KPDGrA+tDYzDeV0268QsCqmX6S4lO46uswgt1LGUeG0EDFGuMd9ac8cWNTA== + jpjs@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/jpjs/-/jpjs-1.2.1.tgz"