mirror of
https://github.com/t4t5/nostr-react.git
synced 2025-03-17 13:31:43 +01:00
Add new useProfile hook
It should batch requests when possible to prevent the "Max connections open" error
This commit is contained in:
parent
f572ae9cdc
commit
c606193c82
@ -68,6 +68,7 @@
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"jotai": "^1.12.1",
|
||||
"nostr-tools": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
225
src/core.tsx
Normal file
225
src/core.tsx
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
228
src/index.tsx
228
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<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
111
src/useProfile.tsx
Normal 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,
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user