Add new useProfile hook

It should batch requests when possible to prevent the "Max connections
open" error
This commit is contained in:
Tristan Edwards 2023-01-01 22:34:44 +01:00
parent f572ae9cdc
commit c606193c82
6 changed files with 349 additions and 225 deletions

View File

@ -68,6 +68,7 @@
"typescript": "^4.9.4"
},
"dependencies": {
"jotai": "^1.12.1",
"nostr-tools": "^1.1.0"
}
}

225
src/core.tsx Normal file
View File

@ -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<NostrContextType>({
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<Relay[]>([])
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 <NostrContext.Provider value={value}>{children}</NostrContext.Provider>
}
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<NostrEvent[]>([])
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
}
},
}
}

View File

@ -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<NostrContextType>({
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<Relay[]>([])
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 <NostrContext.Provider value={value}>{children}</NostrContext.Provider>
}
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<NostrEvent[]>([])
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"

111
src/useProfile.tsx Normal file
View File

@ -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<string[]>([])
const requestedPubkeysAtom = atom<string[]>([])
const fetchedProfilesAtom = atom<Record<string, Metadata>>({})
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<string, Metadata>) => {
return {
..._profiles,
[metaPubkey]: metadata,
}
})
}
} catch (err) {
console.error(err, rawMetadata)
}
})
const metadata = fetchedProfiles[pubkey]
const npub = nip19.npubEncode(pubkey)
return {
...metadata,
npub,
}
}

View File

@ -10,6 +10,10 @@ export const uniqBy = <T>(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()

View File

@ -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"