You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

188 lines
6.9 KiB

import { ExtendedKind } from '@/constants'
import {
getReplaceableCoordinateFromEvent,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { isCalendarEventKind } from '@/lib/calendar-event'
import client from '@/services/client.service'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { tagNameEquals } from '@/lib/tag'
/** NIP-65 inboxes only — calendar RSVPs are published to the author’s outboxes, so REQ must include those too. */
function userWriteRelaysForQuery(
relayList: { write?: string[]; httpWrite?: string[] } | null | undefined
): string[] {
if (!relayList) return []
const ws = (relayList.write ?? [])
.map((url) => normalizeAnyRelayUrl(url) || url)
.filter(Boolean) as string[]
const http = (relayList.httpWrite ?? [])
.map((url) => normalizeAnyRelayUrl(url) || url)
.filter(Boolean) as string[]
return [...http, ...ws]
}
function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined {
const status = rsvp.tags.find(tagNameEquals('status'))?.[1]
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status
return undefined
}
function mergeRsvp(prev: Event[], evt: Event): Event[] {
const next = prev.filter((e) => e.id !== evt.id)
const pk = evt.pubkey.toLowerCase()
const samePubkey = next.find((e) => e.pubkey.toLowerCase() === pk)
if (samePubkey && samePubkey.created_at >= evt.created_at) return next
const withoutSamePubkey = samePubkey ? next.filter((e) => e.pubkey.toLowerCase() !== pk) : next
return [...withoutSamePubkey, evt].sort((a, b) => b.created_at - a.created_at)
}
/** Apply RSVPs in time order so the latest per pubkey wins (matches relay merge semantics). */
function mergeRsvpList(events: Event[]): Event[] {
const asc = [...events].sort((a, b) => a.created_at - b.created_at)
let acc: Event[] = []
for (const e of asc) acc = mergeRsvp(acc, e)
return acc
}
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const { relayList } = useNostr()
const [rsvps, setRsvps] = useState<Event[]>([])
const [isFetching, setIsFetching] = useState(false)
useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) {
setRsvps([])
return
}
let cancelled = false
setIsFetching(true)
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const userRead = userReadRelaysWithHttp(relayList)
const userWrite = userWriteRelaysForQuery(relayList)
void (async () => {
const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent)
setRsvps(mergeRsvpList(fromSession))
const idbP = indexedDb
.getCalendarRsvpEventsByParentCoordinate(coordinate)
.catch((): Event[] => [])
void idbP.then((rows) => {
if (cancelled) return
setRsvps(mergeRsvpList([...rows, ...fromSession]))
})
const baseUrls = new Set<string>([
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...userRead.map((url) => normalizeAnyRelayUrl(url) || url),
...userWrite.map((url) => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[])
const organizerPubkey = calendarEvent.pubkey
try {
let relayUrls: string[]
try {
const organizerRelays = await client.fetchRelayList(organizerPubkey)
if (!cancelled) {
;[
...(organizerRelays?.httpRead ?? []),
...(organizerRelays?.read ?? []),
...(organizerRelays?.httpWrite ?? []),
...(organizerRelays?.write ?? [])
].forEach((url) => {
const u = normalizeAnyRelayUrl(url)
if (u) baseUrls.add(u)
})
}
relayUrls = Array.from(baseUrls)
} catch {
relayUrls = Array.from(baseUrls)
}
if (cancelled) return
const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls)
const calendarHexId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const events = await queryService.fetchEvents(
urls,
[
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
},
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#e': [calendarHexId],
limit: 200
}
],
{
firstRelayResultGraceMs: false,
eoseTimeout: 4500,
globalTimeout: 24_000
}
)
if (cancelled) return
const fromRelay = events ?? []
const fromIdb = await idbP
await Promise.allSettled(
fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined))
)
setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...fromRelay]))
} finally {
if (!cancelled) setIsFetching(false)
}
})()
return () => {
cancelled = true
}
}, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey, relayList])
// When we publish an RSVP, NostrProvider calls client.emitNewEvent(event). Merge it into rsvps so the UI updates immediately.
useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const handler = (e: CustomEvent<Event>) => {
const evt = e.detail
if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const aTag = evt.tags.find(tagNameEquals('a'))
const aCoord = aTag?.[1] ? normalizeReplaceableCoordinateString(aTag[1]) : ''
const eTag = evt.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase()
const matchesA = aCoord !== '' && aCoord === coordinate
const matchesE = eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId
if (!matchesA && !matchesE) return
void indexedDb.putCalendarRsvpEventRow(evt).catch(() => undefined)
setRsvps((prev) => mergeRsvp(prev, evt))
}
client.addEventListener('newEvent', handler as EventListener)
return () => client.removeEventListener('newEvent', handler as EventListener)
}, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey])
return {
rsvps,
isFetching,
getRsvpStatus
}
}