diff --git a/src/components/CalendarEventContent/index.tsx b/src/components/CalendarEventContent/index.tsx index af8319d4..2e1ed58d 100644 --- a/src/components/CalendarEventContent/index.tsx +++ b/src/components/CalendarEventContent/index.tsx @@ -51,7 +51,7 @@ export default function CalendarEventContent({ const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey: myPubkey, publish } = useNostr() - const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event) + const { rsvps, isFetching, getRsvpStatus: getStatus, applyRsvp } = useFetchCalendarRsvps(event) const meta = useMemo(() => { if (!isCalendarEventKind(event.kind)) return null @@ -88,7 +88,8 @@ export default function CalendarEventContent({ return [...httpRs, ...(meta.image?.trim() ? [meta.image.trim()] : [])] }, [meta, event]) - const myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined + const myPk = myPubkey?.toLowerCase() + const myRsvp = myPk ? rsvps.find((r) => r.pubkey.toLowerCase() === myPk) : undefined const myStatus = myRsvp ? getStatus(myRsvp) : undefined // Organizer + invitees (event p tags) + anyone who sent an RSVP. Each shows response: accepted/tentative/declined or no response. @@ -108,12 +109,13 @@ export default function CalendarEventContent({ new Set([organizerPubkey, ...participantPubkeys, ...rsvps.map((r) => r.pubkey)]) ) return allPubkeys.map((pubkey) => { - const rsvp = rsvps.find((r) => r.pubkey === pubkey) + const pk = pubkey.toLowerCase() + const rsvp = rsvps.find((r) => r.pubkey.toLowerCase() === pk) return { pubkey, role: roleByPubkey.get(pubkey), status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null, - isOrganizer: pubkey === organizerPubkey + isOrganizer: pk === organizerPubkey.toLowerCase() } }) }, [event.pubkey, event.tags, rsvps]) @@ -142,7 +144,8 @@ export default function CalendarEventContent({ } try { const draft = createCalendarRsvpDraftEvent(event, status) - await publish(draft) + const signed = await publish(draft) + applyRsvp(signed) toast.success(t('RSVP updated')) } catch (err) { toast.error(err instanceof Error ? err.message : t('Failed to update RSVP')) diff --git a/src/components/OthersRelayList/index.tsx b/src/components/OthersRelayList/index.tsx index 2a2a4d2a..3821c6ee 100644 --- a/src/components/OthersRelayList/index.tsx +++ b/src/components/OthersRelayList/index.tsx @@ -1,5 +1,7 @@ import { useSmartRelayNavigation } from '@/PageManager' +import RelaySettingsKindNotice from '@/components/RelaySettingsKindNotice' import { Badge } from '@/components/ui/badge' +import { kinds } from 'nostr-tools' import { useFetchRelayInfo, useFetchRelayList } from '@/hooks' import { toRelay } from '@/lib/link' import { userIdToPubkey } from '@/lib/pubkey' @@ -19,6 +21,7 @@ export default function OthersRelayList({ userId }: { userId: string }) { return (
+ {showingRelayListFallback && (

+ {t('relaySettingsEventKindsSession')} +

+ ) + } + + if (kinds.length === 0) return null + + const kindsLabel = formatKindList(kinds) + const body = + variant === 'view' + ? t('relaySettingsEventKindsView', { kinds: kindsLabel }) + : kinds.length === 1 + ? t('relaySettingsEventKindsEditOne', { kind: kindsLabel }) + : t('relaySettingsEventKindsEditMany', { kinds: kindsLabel }) + + return ( +

+ {t('relaySettingsEventKindsLabel')}:{' '} + {kindsLabel} + {body} +

+ ) +} diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index 5f72e5ef..6b56e455 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -1,25 +1,23 @@ import { ExtendedKind } from '@/constants' +import { isCalendarEventKind } from '@/lib/calendar-event' +import { + calendarEventHexId, + calendarRsvpMatchesCalendarEvent, + parseCalendarRsvpStatus +} from '@/lib/calendar-rsvp-match' 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 { relayHintsFromEventTags } from '@/lib/relay-list-builder' +import client, { 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 { useCallback, useEffect, useState } from 'react' import { normalizeAnyRelayUrl } from '@/lib/url' import { FAST_READ_RELAY_URLS } from '@/constants' import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' -import { tagNameEquals } from '@/lib/tag' - -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) @@ -38,11 +36,25 @@ function mergeRsvpList(events: Event[]): Event[] { return acc } +function filterMatchingRsvps(calendarEvent: Event, events: Event[]): Event[] { + return events.filter((ev) => calendarRsvpMatchesCalendarEvent(calendarEvent, ev)) +} + export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const { relayList, cacheRelayListEvent } = useNostr() const [rsvps, setRsvps] = useState([]) const [isFetching, setIsFetching] = useState(false) + const applyRsvp = useCallback( + (evt: Event) => { + if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return + if (!calendarRsvpMatchesCalendarEvent(calendarEvent, evt)) return + void indexedDb.putCalendarRsvpEventRow(evt).catch(() => undefined) + setRsvps((prev) => mergeRsvp(prev, evt)) + }, + [calendarEvent] + ) + useEffect(() => { if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) { setRsvps([]) @@ -52,34 +64,35 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { let cancelled = false setIsFetching(true) - const coordinate = normalizeReplaceableCoordinateString( - getReplaceableCoordinateFromEvent(calendarEvent) - ) const userRead = userReadInboxUrls(relayList, cacheRelayListEvent) const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent) void (async () => { - const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) + const fromSession = filterMatchingRsvps( + calendarEvent, + client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) + ) setRsvps(mergeRsvpList(fromSession)) const idbP = indexedDb - .getCalendarRsvpEventsByParentCoordinate(coordinate) + .getCalendarRsvpEventsForCalendarEvent(calendarEvent) .catch((): Event[] => []) void idbP.then((rows) => { if (cancelled) return - setRsvps(mergeRsvpList([...rows, ...fromSession])) + setRsvps(mergeRsvpList(filterMatchingRsvps(calendarEvent, [...rows, ...fromSession]))) }) const baseUrls = new Set([ ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), ...userRead.map((url) => normalizeAnyRelayUrl(url) || url), - ...userWrite.map((url) => normalizeAnyRelayUrl(url) || url) + ...userWrite.map((url) => normalizeAnyRelayUrl(url) || url), + ...relayHintsFromEventTags(calendarEvent).map((url) => normalizeAnyRelayUrl(url) || url), + ...client.getSeenEventRelayUrls(calendarEvent.id).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) { @@ -93,17 +106,17 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { if (u) baseUrls.add(u) }) } - relayUrls = Array.from(baseUrls) } catch { - relayUrls = Array.from(baseUrls) + // keep 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 coordinate = normalizeReplaceableCoordinateString( + getReplaceableCoordinateFromEvent(calendarEvent) + ) + const calendarHexId = calendarEventHexId(calendarEvent) const events = await queryService.fetchEvents( - urls, + Array.from(baseUrls), [ { kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], @@ -123,12 +136,16 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { } ) if (cancelled) return - const fromRelay = events ?? [] + const fromRelay = filterMatchingRsvps(calendarEvent, events ?? []) const fromIdb = await idbP await Promise.allSettled( fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined)) ) - setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...fromRelay])) + setRsvps( + mergeRsvpList( + filterMatchingRsvps(calendarEvent, [...fromIdb, ...fromSession, ...fromRelay]) + ) + ) } finally { if (!cancelled) setIsFetching(false) } @@ -137,38 +154,23 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { return () => { cancelled = true } - }, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey, relayList]) + }, [calendarEvent, relayList, cacheRelayListEvent]) - // 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) => { - 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)) + applyRsvp(e.detail) } client.addEventListener('newEvent', handler as EventListener) return () => client.removeEventListener('newEvent', handler as EventListener) - }, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey]) + }, [calendarEvent, applyRsvp]) return { rsvps, isFetching, - getRsvpStatus + getRsvpStatus: parseCalendarRsvpStatus, + applyRsvp } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 92d2dac7..c7c4c770 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -842,6 +842,15 @@ export default { 'HTTP relays': 'HTTP-Relays', httpRelaysDescription: 'HTTPS-Index-Relays (z. B. REST /api/events/filter). Gleiche Lese-/Schreib-/beides-Rollen wie Mailbox-Relays; gespeichert als Kind 10243. Liste leeren und speichern, um eine leere Liste zu veröffentlichen.', + relaySettingsEventKindsLabel: 'Nostr-Event-Kind', + relaySettingsEventKindsEditOne: + 'Speichern veröffentlicht hier ein ersetzbares Listen-Event dieses Kinds auf deinen Relays.', + relaySettingsEventKindsEditMany: + 'Speichern veröffentlicht hier ersetzbare Listen-Events dieser Kinds auf deinen Relays.', + relaySettingsEventKindsView: + 'Zeigt die veröffentlichte Relay-Liste dieses Nutzers aus Kind {{kinds}}, sofern der Client sie geladen hat.', + relaySettingsEventKindsSession: + 'Kein ersetzbares Relay-Listen-Event — dieser Tab zeigt nur Session-Bewertung und Strafen im Speicher (nichts wird veröffentlicht).', 'HTTP relays saved': 'HTTP-Relays gespeichert', 'Failed to save HTTP relay list': 'HTTP-Relay-Liste konnte nicht gespeichert werden', 'HTTP relays must start with https:// or http://': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 31db8ea1..8a7792f4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -862,6 +862,15 @@ export default { 'HTTP relays': 'HTTP relays', httpRelaysDescription: 'HTTPS index relays (e.g. REST /api/events/filter). Same read/write/both roles as mailbox relays; stored as kind 10243. Clear the list and save to publish an empty list.', + relaySettingsEventKindsLabel: 'Nostr event kind', + relaySettingsEventKindsEditOne: + 'Saving here publishes a replaceable list event of this kind to your relays.', + relaySettingsEventKindsEditMany: + 'Saving here publishes replaceable list events of these kinds to your relays.', + relaySettingsEventKindsView: + 'Shows this user’s published relay list from kind {{kinds}} when the client has fetched it.', + relaySettingsEventKindsSession: + 'No replaceable relay list event — this tab only shows in-memory session relay scoring and strikes (nothing is published).', 'HTTP relays saved': 'HTTP relays saved', 'Failed to save HTTP relay list': 'Failed to save HTTP relay list', 'HTTP relays must start with https:// or http://': diff --git a/src/lib/calendar-rsvp-match.test.ts b/src/lib/calendar-rsvp-match.test.ts new file mode 100644 index 00000000..5fafa455 --- /dev/null +++ b/src/lib/calendar-rsvp-match.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { ExtendedKind } from '@/constants' +import { + calendarEventHexId, + calendarRsvpMatchesCalendarEvent, + calendarRsvpParentKeyFromEventId, + parseCalendarRsvpStatus +} from '@/lib/calendar-rsvp-match' +import type { Event } from 'nostr-tools' + +const ORG = 'b'.repeat(63) + 'c' +const D = 'purple-prague' +const CAL_ID = 'a'.repeat(64) + +function calendarEvent(overrides: Partial = {}): Event { + return { + id: CAL_ID, + pubkey: ORG, + created_at: 1_700_000_000, + kind: ExtendedKind.CALENDAR_EVENT_TIME, + tags: [['d', D]], + content: '', + sig: 'sig', + ...overrides + } +} + +function rsvp(tags: string[][], pubkey = 'c'.repeat(63) + 'd'): Event { + return { + id: 'f'.repeat(64), + pubkey, + created_at: 1_700_000_100, + kind: ExtendedKind.CALENDAR_EVENT_RSVP, + tags, + content: '', + sig: 'sig' + } +} + +describe('calendarRsvpMatchesCalendarEvent', () => { + const cal = calendarEvent() + const coord = `${ExtendedKind.CALENDAR_EVENT_TIME}:${ORG}:${D}` + + it('matches via normalized a tag', () => { + expect( + calendarRsvpMatchesCalendarEvent( + cal, + rsvp([ + ['a', coord], + ['status', 'accepted'] + ]) + ) + ).toBe(true) + }) + + it('matches via e tag when a is absent', () => { + expect( + calendarRsvpMatchesCalendarEvent( + cal, + rsvp([ + ['e', CAL_ID.toUpperCase()], + ['status', 'tentative'] + ]) + ) + ).toBe(true) + }) + + it('rejects wrong coordinate and wrong event id', () => { + expect( + calendarRsvpMatchesCalendarEvent( + cal, + rsvp([ + ['a', `${ExtendedKind.CALENDAR_EVENT_TIME}:${ORG}:other`], + ['e', 'b'.repeat(64)] + ]) + ) + ).toBe(false) + }) +}) + +describe('parseCalendarRsvpStatus', () => { + it('parses accepted, tentative, declined', () => { + expect(parseCalendarRsvpStatus(rsvp([['status', 'Accepted']]))).toBe('accepted') + expect(parseCalendarRsvpStatus(rsvp([['status', 'TENTATIVE']]))).toBe('tentative') + expect(parseCalendarRsvpStatus(rsvp([['status', 'declined']]))).toBe('declined') + }) + + it('returns undefined for missing or invalid status', () => { + expect(parseCalendarRsvpStatus(rsvp([]))).toBeUndefined() + expect(parseCalendarRsvpStatus(rsvp([['status', 'maybe']]))).toBeUndefined() + }) +}) + +describe('calendarEventHexId', () => { + it('lowercases 64-char hex ids', () => { + expect(calendarEventHexId({ ...calendarEvent(), id: CAL_ID.toUpperCase() })).toBe(CAL_ID) + }) +}) + +describe('calendarRsvpParentKeyFromEventId', () => { + it('builds e: prefix key', () => { + expect(calendarRsvpParentKeyFromEventId(CAL_ID)).toBe(`e:${CAL_ID}`) + expect(calendarRsvpParentKeyFromEventId('not-hex')).toBe('') + }) +}) diff --git a/src/lib/calendar-rsvp-match.ts b/src/lib/calendar-rsvp-match.ts new file mode 100644 index 00000000..35a6f35b --- /dev/null +++ b/src/lib/calendar-rsvp-match.ts @@ -0,0 +1,38 @@ +import { ExtendedKind } from '@/constants' +import { + getReplaceableCoordinateFromEvent, + normalizeReplaceableCoordinateString +} from '@/lib/event' +import { tagNameEquals } from '@/lib/tag' +import { Event } from 'nostr-tools' + +export type CalendarRsvpStatus = 'accepted' | 'tentative' | 'declined' + +export function calendarEventHexId(event: Event): string { + return /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id +} + +/** Whether kind 31925 references this calendar note (31922 / 31923) via `a` and/or `e`. */ +export function calendarRsvpMatchesCalendarEvent(calendarEvent: Event, rsvp: Event): boolean { + if (rsvp.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return false + const coordNorm = normalizeReplaceableCoordinateString( + getReplaceableCoordinateFromEvent(calendarEvent) + ) + const calId = calendarEventHexId(calendarEvent) + const rawA = rsvp.tags.find(tagNameEquals('a'))?.[1]?.trim() + if (rawA && normalizeReplaceableCoordinateString(rawA) === coordNorm) return true + const eTag = rsvp.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase() + return Boolean(eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId) +} + +export function parseCalendarRsvpStatus(rsvp: Event): CalendarRsvpStatus | undefined { + const status = rsvp.tags.find(tagNameEquals('status'))?.[1]?.trim().toLowerCase() + if (status === 'accepted' || status === 'tentative' || status === 'declined') return status + return undefined +} + +/** IndexedDB parent key for RSVPs that only tag the calendar event id (`e`). */ +export function calendarRsvpParentKeyFromEventId(hexId: string): string { + const id = hexId.trim().toLowerCase() + return /^[0-9a-f]{64}$/.test(id) ? `e:${id}` : '' +} diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index d0a125a9..7210190b 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -3,6 +3,7 @@ import HttpRelaysSetting from '@/components/HttpRelaysSetting' import JsonViewDialog from '@/components/JsonViewDialog' import MailboxSetting from '@/components/MailboxSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' +import RelaySettingsKindNotice from '@/components/RelaySettingsKindNotice' import SessionRelaysTab from '@/components/SessionRelaysTab' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' @@ -23,6 +24,12 @@ import { kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +const FAVORITE_TAB_KINDS = [ + ExtendedKind.FAVORITE_RELAYS, + ExtendedKind.BLOCKED_RELAYS, + kinds.Relaysets +] as const + const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() @@ -128,19 +135,24 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: {t('Cache Relays')} {t('Session relays')} - + + - + + - + + - + + - + + diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 36626889..18b0282d 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -47,6 +47,7 @@ import { } from './event-archive.service' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { isCalendarEventKind } from '@/lib/calendar-event' +import { calendarRsvpMatchesCalendarEvent } from '@/lib/calendar-rsvp-match' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' @@ -1045,25 +1046,10 @@ export class EventService { */ getSessionCalendarRsvpsForCalendarEvent(calendarEvent: NEvent): NEvent[] { if (!isCalendarEventKind(calendarEvent.kind)) return [] - const coordNorm = normalizeReplaceableCoordinateString( - getReplaceableCoordinateFromEvent(calendarEvent) - ) - const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id) - ? calendarEvent.id.toLowerCase() - : calendarEvent.id const out: NEvent[] = [] for (const [, event] of this.sessionEventCache.entries()) { - if (event.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) continue if (shouldDropEventOnIngest(event)) continue - const rawA = event.tags.find(tagNameEquals('a'))?.[1]?.trim() - if (rawA && normalizeReplaceableCoordinateString(rawA) === coordNorm) { - out.push(event) - continue - } - const eTag = event.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase() - if (eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId) { - out.push(event) - } + if (calendarRsvpMatchesCalendarEvent(calendarEvent, event)) out.push(event) } return out.sort((a, b) => b.created_at - a.created_at) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 8e0117da..bfb354fd 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -15,6 +15,7 @@ import { getCalendarOccurrenceWindowMs, isCalendarEventKind } from '@/lib/calendar-event' +import { calendarEventHexId, calendarRsvpParentKeyFromEventId } from '@/lib/calendar-rsvp-match' import { getReplaceableCoordinate, getReplaceableCoordinateFromEvent, @@ -3784,12 +3785,20 @@ class IndexedDbService { }) } - /** Persist a NIP-52 RSVP (31925). Indexed by normalized `a` parent coordinate. */ + /** Persist a NIP-52 RSVP (31925). Indexed by normalized `a` coordinate or `e:`. */ async putCalendarRsvpEventRow(ev: Event): Promise { if (ev.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return const rawA = ev.tags.find(tagNameEquals('a'))?.[1]?.trim() - if (!rawA) return - const parentCoordinate = normalizeReplaceableCoordinateString(rawA) + const rawE = ev.tags.find(tagNameEquals('e'))?.[1]?.trim() + const eHex = + rawE && /^[0-9a-f]{64}$/i.test(rawE) ? rawE.toLowerCase() : '' + let parentCoordinate = '' + if (rawA) { + parentCoordinate = normalizeReplaceableCoordinateString(rawA) + } else if (eHex) { + parentCoordinate = `e:${eHex}` + } + if (!parentCoordinate) return await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) return @@ -3860,7 +3869,29 @@ class IndexedDbService { }) } - /** Cached RSVPs for a calendar replaceable coordinate (`kind:pubkey:d`). */ + /** RSVPs for a calendar note: `a` coordinate index plus `e:` rows. */ + async getCalendarRsvpEventsForCalendarEvent(calendarEvent: Event, limit = 400): Promise { + const coord = normalizeReplaceableCoordinateString( + getReplaceableCoordinateFromEvent(calendarEvent) + ) + const eKey = calendarRsvpParentKeyFromEventId(calendarEventHexId(calendarEvent)) + const [byCoord, byE] = await Promise.all([ + this.getCalendarRsvpEventsByParentCoordinate(coord, limit), + eKey ? this.getCalendarRsvpEventsByParentCoordinate(eKey, limit) : Promise.resolve([]) + ]) + const seen = new Set() + const out: Event[] = [] + for (const ev of [...byCoord, ...byE]) { + const id = ev.id.toLowerCase() + if (seen.has(id)) continue + seen.add(id) + out.push(ev) + } + out.sort((a, b) => b.created_at - a.created_at) + return out.slice(0, limit) + } + + /** Cached RSVPs for a calendar replaceable coordinate (`kind:pubkey:d`) or `e:`. */ async getCalendarRsvpEventsByParentCoordinate( parentCoordinate: string, limit = 400