feat: sync relay sets

This commit is contained in:
codytseng
2025-01-07 23:26:05 +08:00
parent 4343765aba
commit 7bd5b915eb
38 changed files with 1069 additions and 686 deletions

View File

@@ -1,9 +1,20 @@
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import storage from '@/services/storage.service'
import { TFeedType } from '@/types'
import { createContext, useContext, useState } from 'react'
import { Filter } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider'
import { useRelaySets } from './RelaySetsProvider'
type TFeedContext = {
feedType: TFeedType
setFeedType: (feedType: TFeedType) => void
relayUrls: string[]
temporaryRelayUrls: string[]
filter: Filter
isReady: boolean
activeRelaySetId: string | null
switchFeed: (feedType: TFeedType, options?: { activeRelaySetId?: string }) => Promise<void>
}
const FeedContext = createContext<TFeedContext | undefined>(undefined)
@@ -17,7 +28,113 @@ export const useFeed = () => {
}
export function FeedProvider({ children }: { children: React.ReactNode }) {
const { pubkey } = useNostr()
const { relaySets } = useRelaySets()
const [feedType, setFeedType] = useState<TFeedType>('relays')
const [relayUrls, setRelayUrls] = useState<string[]>([])
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
const [filter, setFilter] = useState<Filter>({})
const [isReady, setIsReady] = useState(false)
const [activeRelaySetId, setActiveRelaySetId] = useState<string | null>(() =>
storage.getActiveRelaySetId()
)
return <FeedContext.Provider value={{ feedType, setFeedType }}>{children}</FeedContext.Provider>
useEffect(() => {
const init = async () => {
// temporary relay urls from query params
const searchParams = new URLSearchParams(window.location.search)
const tempRelays = searchParams
.getAll('r')
.map((url) =>
!url.startsWith('ws://') && !url.startsWith('wss://') ? `wss://${url}` : url
)
.filter((url) => isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
if (tempRelays.length) {
setTemporaryRelayUrls(tempRelays)
return await switchFeed('temporary')
}
await switchFeed('relays', { activeRelaySetId })
}
init()
}, [])
useEffect(() => {
if (feedType !== 'following') return
switchFeed('following')
}, [pubkey])
useEffect(() => {
if (feedType !== 'relays') return
const relaySet = relaySets.find((set) => set.id === activeRelaySetId)
if (!relaySet) return
setRelayUrls(relaySet.relayUrls)
}, [relaySets])
const switchFeed = async (
feedType: TFeedType,
options: { activeRelaySetId?: string | null } = {}
) => {
setIsReady(false)
if (feedType === 'relays') {
const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null)
if (!relaySetId) return
const relaySet =
relaySets.find((set) => set.id === options.activeRelaySetId) ??
(relaySets.length > 0 ? relaySets[0] : null)
if (relaySet) {
setFeedType(feedType)
setRelayUrls(relaySet.relayUrls)
setActiveRelaySetId(relaySet.id)
setFilter({})
setIsReady(true)
storage.setActiveRelaySetId(relaySet.id)
}
return
}
if (feedType === 'following') {
if (!pubkey) return
setFeedType(feedType)
setActiveRelaySetId(null)
const [relayList, followings] = await Promise.all([
client.fetchRelayList(pubkey),
client.fetchFollowings(pubkey)
])
setRelayUrls(relayList.read.slice(0, 4))
setFilter({ authors: followings.includes(pubkey) ? followings : [...followings, pubkey] })
setIsReady(true)
return
}
if (feedType === 'temporary') {
setFeedType(feedType)
setRelayUrls(temporaryRelayUrls)
setActiveRelaySetId(null)
setFilter({})
setIsReady(true)
return
}
setIsReady(true)
}
return (
<FeedContext.Provider
value={{
feedType,
relayUrls,
temporaryRelayUrls,
filter,
isReady,
activeRelaySetId,
switchFeed
}}
>
{children}
</FeedContext.Provider>
)
}

View File

@@ -7,7 +7,6 @@ import { ISigner, TAccount, TAccountPointer, TDraftEvent, TRelayList } from '@/t
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react'
import { useRelaySettings } from '../RelaySettingsProvider'
import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer'
import { NsecSigner } from './nsec.signer'
@@ -47,7 +46,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [account, setAccount] = useState<TAccountPointer | null>(null)
const [signer, setSigner] = useState<ISigner | null>(null)
const [openLoginDialog, setOpenLoginDialog] = useState(false)
const { relayUrls: currentRelayUrls } = useRelaySettings()
const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey)
const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey)
@@ -196,10 +194,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
const event = await signEvent(draftEvent)
await client.publishEvent(
relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls),
event
)
await client.publishEvent(relayList.write.concat(additionalRelayUrls), event)
return event
}

View File

@@ -0,0 +1,80 @@
import { randomString } from '@/lib/random'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import storage from '@/services/storage.service'
import { TRelaySet } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react'
type TRelaySetsContext = {
relaySets: TRelaySet[]
addRelaySet: (relaySetName: string, relayUrls?: string[]) => string
deleteRelaySet: (id: string) => void
updateRelaySet: (newSet: TRelaySet) => void
mergeRelaySets: (newSets: TRelaySet[]) => void
}
const RelaySetsContext = createContext<TRelaySetsContext | undefined>(undefined)
export const useRelaySets = () => {
const context = useContext(RelaySetsContext)
if (!context) {
throw new Error('useRelaySets must be used within a RelaySetsProvider')
}
return context
}
export function RelaySetsProvider({ children }: { children: React.ReactNode }) {
const [relaySets, setRelaySets] = useState<TRelaySet[]>(() => storage.getRelaySets())
useEffect(() => {
storage.setRelaySets(relaySets)
}, [relaySets])
const deleteRelaySet = (id: string) => {
setRelaySets((pre) => pre.filter((set) => set.id !== id))
}
const updateRelaySet = (newSet: TRelaySet) => {
setRelaySets((pre) => {
return pre.map((set) => (set.id === newSet.id ? newSet : set))
})
}
const addRelaySet = (relaySetName: string, relayUrls: string[] = []) => {
const normalizedUrls = relayUrls
.filter((url) => isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
const id = randomString()
setRelaySets((pre) => {
return [
...pre,
{
id,
name: relaySetName,
relayUrls: normalizedUrls
}
]
})
return id
}
const mergeRelaySets = (newSets: TRelaySet[]) => {
setRelaySets((pre) => {
const newIds = newSets.map((set) => set.id)
return pre.filter((set) => !newIds.includes(set.id)).concat(newSets)
})
}
return (
<RelaySetsContext.Provider
value={{
relaySets: relaySets,
addRelaySet,
deleteRelaySet,
updateRelaySet,
mergeRelaySets
}}
>
{children}
</RelaySetsContext.Provider>
)
}

View File

@@ -1,178 +0,0 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { checkAlgoRelay, checkSearchRelay } from '@/lib/relay'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import storage from '@/services/storage.service'
import { TRelayGroup } from '@/types'
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
import { useFeed } from './FeedProvider'
type TRelaySettingsContext = {
relayGroups: TRelayGroup[]
temporaryRelayUrls: string[]
relayUrls: string[]
searchableRelayUrls: string[]
areAlgoRelays: boolean
switchRelayGroup: (groupName: string) => void
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
deleteRelayGroup: (groupName: string) => void
addRelayGroup: (groupName: string, relayUrls?: string[]) => string | null
updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void
setTemporaryRelayUrls: Dispatch<string[]>
}
const RelaySettingsContext = createContext<TRelaySettingsContext | undefined>(undefined)
export const useRelaySettings = () => {
const context = useContext(RelaySettingsContext)
if (!context) {
throw new Error('useRelaySettings must be used within a RelaySettingsProvider')
}
return context
}
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
const { setFeedType } = useFeed()
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
const [relayUrls, setRelayUrls] = useState<string[]>(
temporaryRelayUrls.length
? temporaryRelayUrls
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
)
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>(SEARCHABLE_RELAY_URLS)
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)
const tempRelays = searchParams
.getAll('r')
.map((url) => (url.startsWith('wss://') || url.startsWith('ws://') ? url : `wss://${url}`))
.filter((url) => isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
if (tempRelays.length) {
setTemporaryRelayUrls(tempRelays)
setFeedType('relays')
}
const storedGroups = storage.getRelayGroups()
setRelayGroups(storedGroups)
}, [])
useEffect(() => {
const handler = async () => {
const newRelayUrls = temporaryRelayUrls.length
? temporaryRelayUrls
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) {
setRelayUrls(newRelayUrls)
}
const relayInfos = await client.fetchRelayInfos(newRelayUrls)
const searchableRelayUrls = newRelayUrls.filter((_, index) =>
checkSearchRelay(relayInfos[index])
)
setSearchableRelayUrls(
searchableRelayUrls.length ? searchableRelayUrls : SEARCHABLE_RELAY_URLS
)
const nonAlgoRelayUrls = newRelayUrls.filter((_, index) => !checkAlgoRelay(relayInfos[index]))
setAreAlgoRelays(newRelayUrls.length > 0 && nonAlgoRelayUrls.length === 0)
client.setCurrentRelayUrls(nonAlgoRelayUrls)
}
handler()
}, [relayGroups, temporaryRelayUrls, relayUrls])
const updateGroups = (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
let newGroups = relayGroups
setRelayGroups((pre) => {
newGroups = fn(pre)
return newGroups
})
storage.setRelayGroups(newGroups)
}
const switchRelayGroup = (groupName: string) => {
updateGroups((pre) =>
pre.map((group) => ({
...group,
isActive: group.groupName === groupName
}))
)
setFeedType('relays')
setTemporaryRelayUrls([])
}
const deleteRelayGroup = (groupName: string) => {
updateGroups((pre) => pre.filter((group) => group.groupName !== groupName))
}
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
updateGroups((pre) =>
pre.map((group) => ({
...group,
relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
}))
)
}
const renameRelayGroup = (oldGroupName: string, newGroupName: string) => {
if (newGroupName === '') {
return null
}
if (oldGroupName === newGroupName) {
return null
}
updateGroups((pre) => {
if (pre.some((group) => group.groupName === newGroupName)) {
return pre
}
return pre.map((group) => ({
...group,
groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
}))
})
return null
}
const addRelayGroup = (groupName: string, relayUrls: string[] = []) => {
if (groupName === '') {
return null
}
const normalizedUrls = relayUrls
.filter((url) => isWebsocketUrl(url))
.map((url) => normalizeUrl(url))
updateGroups((pre) => {
if (pre.some((group) => group.groupName === groupName)) {
return pre
}
return [
...pre,
{
groupName,
relayUrls: normalizedUrls,
isActive: false
}
]
})
return null
}
return (
<RelaySettingsContext.Provider
value={{
relayGroups,
temporaryRelayUrls,
relayUrls,
searchableRelayUrls,
areAlgoRelays,
switchRelayGroup,
renameRelayGroup,
deleteRelayGroup,
addRelayGroup,
updateRelayGroupRelayUrls,
setTemporaryRelayUrls
}}
>
{children}
</RelaySettingsContext.Provider>
)
}