feat: favorite relays (#250)

This commit is contained in:
Cody Tseng
2025-04-05 15:31:34 +08:00
committed by GitHub
parent fab9ff88b5
commit c739d9d28c
63 changed files with 1081 additions and 982 deletions

View File

@@ -0,0 +1,226 @@
import { BIG_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { createFavoriteRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event'
import { getRelaySetFromRelaySetEvent, getReplaceableEventIdentifier } from '@/lib/event'
import { randomString } from '@/lib/random'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import { TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider'
type TFavoriteRelaysContext = {
favoriteRelays: string[]
addFavoriteRelays: (relayUrls: string[]) => Promise<void>
deleteFavoriteRelays: (relayUrls: string[]) => Promise<void>
relaySets: TRelaySet[]
addRelaySet: (relaySetName: string, relayUrls?: string[]) => Promise<void>
deleteRelaySet: (id: string) => Promise<void>
updateRelaySet: (newSet: TRelaySet) => Promise<void>
}
const FavoriteRelaysContext = createContext<TFavoriteRelaysContext | undefined>(undefined)
export const useFavoriteRelays = () => {
const context = useContext(FavoriteRelaysContext)
if (!context) {
throw new Error('useFavoriteRelays must be used within a FavoriteRelaysProvider')
}
return context
}
export function FavoriteRelaysProvider({ children }: { children: React.ReactNode }) {
const { favoriteRelaysEvent, updateFavoriteRelaysEvent, pubkey, relayList, publish } = useNostr()
const [favoriteRelays, setFavoriteRelays] = useState<string[]>([])
const [relaySetEvents, setRelaySetEvents] = useState<Event[]>([])
const [relaySets, setRelaySets] = useState<TRelaySet[]>([])
useEffect(() => {
if (!favoriteRelaysEvent) {
const favoriteRelays: string[] = DEFAULT_FAVORITE_RELAYS
const storedRelaySets = storage.getRelaySets()
storedRelaySets.forEach(({ relayUrls }) => {
relayUrls.forEach((url) => {
if (!favoriteRelays.includes(url)) {
favoriteRelays.push(url)
}
})
})
setFavoriteRelays(favoriteRelays)
setRelaySetEvents([])
return
}
const init = async () => {
const relays: string[] = []
const relaySetIds: string[] = []
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (!tagValue) return
if (tagName === 'relay') {
const normalizedUrl = normalizeUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl)
}
} else if (tagName === 'a') {
const [kind, author, relaySetId] = tagValue.split(':')
if (kind !== kinds.Relaysets.toString()) return
if (!pubkey || author !== pubkey) return // TODO: support others relay sets
if (!relaySetId) return
if (!relaySetIds.includes(relaySetId)) {
relaySetIds.push(relaySetId)
}
}
})
setFavoriteRelays(relays)
if (!pubkey) return
const relaySetEvents = await Promise.all(
relaySetIds.map((id) => indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id))
)
const nonExistingRelaySetIds = relaySetIds.filter((_, index) => {
return !relaySetEvents[index]
})
if (nonExistingRelaySetIds.length) {
const newRelaySetEvents = await client.fetchEvents(
(relayList?.write ?? []).concat(BIG_RELAY_URLS).slice(0, 5),
{
kinds: [kinds.Relaysets],
authors: [pubkey],
'#d': nonExistingRelaySetIds
}
)
const relaySetEventMap = new Map<string, Event>()
newRelaySetEvents.forEach((event) => {
const d = getReplaceableEventIdentifier(event)
if (!d) return
const old = relaySetEventMap.get(d)
if (!old || old.created_at < event.created_at) {
relaySetEventMap.set(d, event)
}
})
await Promise.all(
Array.from(relaySetEventMap.values()).map((event) => {
return indexedDb.putReplaceableEvent(event)
})
)
nonExistingRelaySetIds.forEach((id) => {
const event = relaySetEventMap.get(id)
if (event) {
const index = relaySetIds.indexOf(id)
if (index !== -1) {
relaySetEvents[index] = event
}
}
})
}
setRelaySetEvents(relaySetEvents.filter(Boolean) as Event[])
}
init()
}, [favoriteRelaysEvent])
useEffect(() => {
setRelaySets(
relaySetEvents.map((evt) => getRelaySetFromRelaySetEvent(evt)).filter(Boolean) as TRelaySet[]
)
}, [relaySetEvents])
const addFavoriteRelays = async (relayUrls: string[]) => {
const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl))
.filter((url) => !!url && !favoriteRelays.includes(url))
if (!normalizedUrls.length) return
const draftEvent = createFavoriteRelaysDraftEvent(
[...favoriteRelays, ...normalizedUrls],
relaySetEvents
)
const newFavoriteRelaysEvent = await publish(draftEvent)
updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const deleteFavoriteRelays = async (relayUrls: string[]) => {
const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl))
.filter((url) => !!url && favoriteRelays.includes(url))
if (!normalizedUrls.length) return
const draftEvent = createFavoriteRelaysDraftEvent(
favoriteRelays.filter((url) => !normalizedUrls.includes(url)),
relaySetEvents
)
const newFavoriteRelaysEvent = await publish(draftEvent)
updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const addRelaySet = async (relaySetName: string, relayUrls: string[] = []) => {
const normalizedUrls = relayUrls
.map((url) => normalizeUrl(url))
.filter((url) => isWebsocketUrl(url))
const id = randomString()
const relaySetDraftEvent = createRelaySetDraftEvent({
id,
name: relaySetName,
relayUrls: normalizedUrls
})
const newRelaySetEvent = await publish(relaySetDraftEvent)
await indexedDb.putReplaceableEvent(newRelaySetEvent)
const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [
...relaySetEvents,
newRelaySetEvent
])
const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent)
updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const deleteRelaySet = async (id: string) => {
const newRelaySetEvents = relaySetEvents.filter((event) => {
return getReplaceableEventIdentifier(event) !== id
})
if (newRelaySetEvents.length === relaySetEvents.length) return
const draftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, newRelaySetEvents)
const newFavoriteRelaysEvent = await publish(draftEvent)
updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const updateRelaySet = async (newSet: TRelaySet) => {
const draftEvent = createRelaySetDraftEvent(newSet)
const newRelaySetEvent = await publish(draftEvent)
await indexedDb.putReplaceableEvent(newRelaySetEvent)
setRelaySetEvents((prev) => {
return prev.map((event) => {
if (getReplaceableEventIdentifier(event) === newSet.id) {
return newRelaySetEvent
}
return event
})
})
}
return (
<FavoriteRelaysContext.Provider
value={{
favoriteRelays,
addFavoriteRelays,
deleteFavoriteRelays,
relaySets,
addRelaySet,
deleteRelaySet,
updateRelaySet
}}
>
{children}
</FavoriteRelaysContext.Provider>
)
}

View File

@@ -1,24 +1,24 @@
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { checkAlgoRelay } from '@/lib/relay'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import relayInfoService from '@/services/relay-info.service'
import { TFeedType } from '@/types'
import { TFeedInfo, TFeedType } from '@/types'
import { Filter } from 'nostr-tools'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
import { useNostr } from './NostrProvider'
import { useRelaySets } from './RelaySetsProvider'
type TFeedContext = {
feedType: TFeedType
feedInfo: TFeedInfo
relayUrls: string[]
temporaryRelayUrls: string[]
filter: Filter
isReady: boolean
activeRelaySetId: string | null
switchFeed: (
feedType: TFeedType,
options?: { activeRelaySetId?: string; pubkey?: string }
options?: { activeRelaySetId?: string; pubkey?: string; relay?: string | null }
) => Promise<void>
}
@@ -35,16 +35,16 @@ export const useFeed = () => {
export function FeedProvider({ children }: { children: React.ReactNode }) {
const isFirstRenderRef = useRef(true)
const { pubkey } = useNostr()
const { relaySets } = useRelaySets()
const feedTypeRef = useRef<TFeedType>(storage.getFeedType())
const [feedType, setFeedType] = useState<TFeedType>(feedTypeRef.current)
const { relaySets, favoriteRelays } = useFavoriteRelays()
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()
)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'relay',
id: DEFAULT_FAVORITE_RELAYS[0]
})
const feedInfoRef = useRef<TFeedInfo>(feedInfo)
useEffect(() => {
const init = async () => {
@@ -60,13 +60,33 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
if (temporaryRelayUrls.length) {
return await switchFeed('temporary', { temporaryRelayUrls })
}
}
if (feedTypeRef.current === 'relays') {
return await switchFeed('relays', { activeRelaySetId })
if (feedInfoRef.current.feedType === 'temporary') {
return
}
let feedInfo: TFeedInfo = {
feedType: 'relay',
id: favoriteRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
if (pubkey) {
const storedFeedInfo = storage.getFeedInfo(pubkey)
if (storedFeedInfo) {
feedInfo = storedFeedInfo
}
}
if (feedTypeRef.current === 'following' && pubkey) {
if (feedInfo.feedType === 'relays') {
return await switchFeed('relays', { activeRelaySetId: feedInfo.id })
}
if (feedInfo.feedType === 'relay') {
return await switchFeed('relay', { relay: feedInfo.id })
}
// update following feed if pubkey changes
if (feedInfo.feedType === 'following' && pubkey) {
return await switchFeed('following', { pubkey })
}
}
@@ -80,26 +100,46 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
activeRelaySetId?: string | null
temporaryRelayUrls?: string[] | null
pubkey?: string | null
relay?: string | null
} = {}
) => {
setIsReady(false)
if (feedType === 'relay') {
const normalizedUrl = normalizeUrl(options.relay ?? '')
if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) {
setIsReady(true)
return
}
const newFeedInfo = { feedType, id: normalizedUrl }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
setRelayUrls([normalizedUrl])
setFilter({})
storage.setFeedInfo(newFeedInfo, pubkey)
setIsReady(true)
const relayInfo = await relayInfoService.getRelayInfo(normalizedUrl)
client.setCurrentRelayUrls(checkAlgoRelay(relayInfo) ? [] : [normalizedUrl])
return
}
if (feedType === 'relays') {
const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null)
if (!relaySetId) {
return setIsReady(true)
setIsReady(true)
return
}
const relaySet =
relaySets.find((set) => set.id === options.activeRelaySetId) ??
(relaySets.length > 0 ? relaySets[0] : null)
if (relaySet) {
feedTypeRef.current = feedType
setFeedType(feedType)
const newFeedInfo = { feedType, id: relaySet.id }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
setRelayUrls(relaySet.relayUrls)
setActiveRelaySetId(relaySet.id)
setFilter({})
storage.setActiveRelaySetId(relaySet.id)
storage.setFeedType(feedType)
storage.setFeedInfo(newFeedInfo, pubkey)
setIsReady(true)
const relayInfos = await relayInfoService.getRelayInfos(relaySet.relayUrls)
@@ -107,21 +147,23 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
relaySet.relayUrls.filter((_, i) => !relayInfos[i] || !checkAlgoRelay(relayInfos[i]))
)
}
return setIsReady(true)
setIsReady(true)
return
}
if (feedType === 'following') {
if (!options.pubkey) {
return setIsReady(true)
}
feedTypeRef.current = feedType
setFeedType(feedType)
setActiveRelaySetId(null)
const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
storage.setFeedInfo(newFeedInfo, pubkey)
const followings = await client.fetchFollowings(options.pubkey, true)
setRelayUrls([])
setFilter({
authors: followings.includes(options.pubkey) ? followings : [...followings, options.pubkey]
})
storage.setFeedType(feedType)
return setIsReady(true)
}
if (feedType === 'temporary') {
@@ -130,11 +172,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return setIsReady(true)
}
feedTypeRef.current = feedType
setFeedType(feedType)
const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
setTemporaryRelayUrls(urls)
setRelayUrls(urls)
setActiveRelaySetId(null)
setFilter({})
setIsReady(true)
@@ -150,12 +192,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return (
<FeedContext.Provider
value={{
feedType,
feedInfo,
relayUrls,
temporaryRelayUrls,
filter,
isReady,
activeRelaySetId,
switchFeed
}}
>

View File

@@ -1,5 +1,5 @@
import LoginDialog from '@/components/LoginDialog'
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { useToast } from '@/hooks'
import {
getLatestEvent,
@@ -30,6 +30,7 @@ type TNostrContext = {
relayList: TRelayList | null
followListEvent?: Event
muteListEvent?: Event
favoriteRelaysEvent?: Event
account: TAccountPointer | null
accounts: TAccountPointer[]
nsec: string | null
@@ -55,6 +56,7 @@ type TNostrContext = {
updateProfileEvent: (profileEvent: Event) => Promise<void>
updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
}
const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -80,6 +82,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | undefined>(undefined)
useEffect(() => {
const init = async () => {
@@ -131,13 +134,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} else {
setNcryptsec(null)
}
const [storedRelayListEvent, storedProfileEvent, storedFollowListEvent, storedMuteListEvent] =
await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist)
])
const [
storedRelayListEvent,
storedProfileEvent,
storedFollowListEvent,
storedMuteListEvent,
storedFavoriteRelaysEvent
] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS)
])
if (storedRelayListEvent) {
setRelayList(
storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null
@@ -153,6 +162,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (storedMuteListEvent) {
setMuteListEvent(storedMuteListEvent)
}
if (storedFavoriteRelaysEvent) {
setFavoriteRelaysEvent(storedFavoriteRelaysEvent)
}
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList],
@@ -167,13 +179,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setRelayList(relayList)
const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), {
kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist],
kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist, ExtendedKind.FAVORITE_RELAYS],
authors: [account.pubkey]
})
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
if (profileEvent) {
setProfileEvent(profileEvent)
setProfile(getProfileFromProfileEvent(profileEvent))
@@ -192,6 +205,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setMuteListEvent(muteListEvent)
await indexedDb.putReplaceableEvent(muteListEvent)
}
if (favoriteRelaysEvent) {
setFavoriteRelaysEvent(favoriteRelaysEvent)
await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
}
client.initUserIndexFromFollowings(account.pubkey, controller.signal)
return controller
@@ -414,8 +431,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.ShortTextNote,
kinds.Reaction,
kinds.Repost,
COMMENT_EVENT_KIND,
PICTURE_EVENT_KIND
ExtendedKind.COMMENT,
ExtendedKind.PICTURE
].includes(draftEvent.kind)
) {
const mentions: string[] = []
@@ -509,6 +526,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setMuteListEvent(muteListEvent)
}
const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
return (
<NostrContext.Provider
value={{
@@ -518,6 +542,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
relayList,
followListEvent,
muteListEvent,
favoriteRelaysEvent,
account,
accounts: storage
.getAccounts()
@@ -541,7 +566,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateRelayListEvent,
updateProfileEvent,
updateFollowListEvent,
updateMuteListEvent
updateMuteListEvent,
updateFavoriteRelaysEvent
}}
>
{children}

View File

@@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants'
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
@@ -73,7 +73,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
{
kinds: [
kinds.ShortTextNote,
COMMENT_EVENT_KIND,
ExtendedKind.COMMENT,
kinds.Reaction,
kinds.Repost,
kinds.Zap

View File

@@ -1,80 +0,0 @@
import { randomString } from '@/lib/random'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import storage from '@/services/local-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,
addRelaySet,
deleteRelaySet,
updateRelaySet,
mergeRelaySets
}}
>
{children}
</RelaySetsContext.Provider>
)
}