Browse Source

fix calendar rsvps

imwald
Silberengel 2 weeks ago
parent
commit
c85b63ef18
  1. 13
      src/components/CalendarEventContent/index.tsx
  2. 3
      src/components/OthersRelayList/index.tsx
  3. 60
      src/components/RelaySettingsKindNotice/index.tsx
  4. 98
      src/hooks/useFetchCalendarRsvps.tsx
  5. 9
      src/i18n/locales/de.ts
  6. 9
      src/i18n/locales/en.ts
  7. 105
      src/lib/calendar-rsvp-match.test.ts
  8. 38
      src/lib/calendar-rsvp-match.ts
  9. 22
      src/pages/secondary/RelaySettingsPage/index.tsx
  10. 18
      src/services/client-events.service.ts
  11. 39
      src/services/indexed-db.service.ts

13
src/components/CalendarEventContent/index.tsx

@ -51,7 +51,7 @@ export default function CalendarEventContent({
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey: myPubkey, publish } = useNostr() const { pubkey: myPubkey, publish } = useNostr()
const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event) const { rsvps, isFetching, getRsvpStatus: getStatus, applyRsvp } = useFetchCalendarRsvps(event)
const meta = useMemo(() => { const meta = useMemo(() => {
if (!isCalendarEventKind(event.kind)) return null if (!isCalendarEventKind(event.kind)) return null
@ -88,7 +88,8 @@ export default function CalendarEventContent({
return [...httpRs, ...(meta.image?.trim() ? [meta.image.trim()] : [])] return [...httpRs, ...(meta.image?.trim() ? [meta.image.trim()] : [])]
}, [meta, event]) }, [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 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. // 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)]) new Set([organizerPubkey, ...participantPubkeys, ...rsvps.map((r) => r.pubkey)])
) )
return allPubkeys.map((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 { return {
pubkey, pubkey,
role: roleByPubkey.get(pubkey), role: roleByPubkey.get(pubkey),
status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null, status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null,
isOrganizer: pubkey === organizerPubkey isOrganizer: pk === organizerPubkey.toLowerCase()
} }
}) })
}, [event.pubkey, event.tags, rsvps]) }, [event.pubkey, event.tags, rsvps])
@ -142,7 +144,8 @@ export default function CalendarEventContent({
} }
try { try {
const draft = createCalendarRsvpDraftEvent(event, status) const draft = createCalendarRsvpDraftEvent(event, status)
await publish(draft) const signed = await publish(draft)
applyRsvp(signed)
toast.success(t('RSVP updated')) toast.success(t('RSVP updated'))
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : t('Failed to update RSVP')) toast.error(err instanceof Error ? err.message : t('Failed to update RSVP'))

3
src/components/OthersRelayList/index.tsx

@ -1,5 +1,7 @@
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
import RelaySettingsKindNotice from '@/components/RelaySettingsKindNotice'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { kinds } from 'nostr-tools'
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks' import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
@ -19,6 +21,7 @@ export default function OthersRelayList({ userId }: { userId: string }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<RelaySettingsKindNotice kinds={[kinds.RelayList]} variant="view" />
{showingRelayListFallback && ( {showingRelayListFallback && (
<p <p
className="rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-sm text-foreground" className="rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-sm text-foreground"

60
src/components/RelaySettingsKindNotice/index.tsx

@ -0,0 +1,60 @@
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
export type RelaySettingsKindNoticeVariant = 'edit' | 'view' | 'session'
type Props = {
kinds: readonly number[]
variant?: RelaySettingsKindNoticeVariant
className?: string
}
function formatKindList(kinds: readonly number[]): string {
return kinds.join(', ')
}
export default function RelaySettingsKindNotice({
kinds,
variant = 'edit',
className
}: Props) {
const { t } = useTranslation()
if (variant === 'session') {
return (
<p
className={cn(
'rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground',
className
)}
role="note"
>
{t('relaySettingsEventKindsSession')}
</p>
)
}
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 (
<p
className={cn(
'rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground',
className
)}
role="note"
>
<span className="font-medium text-foreground">{t('relaySettingsEventKindsLabel')}:</span>{' '}
<span className="font-mono tabular-nums">{kindsLabel}</span>
<span className="block mt-1">{body}</span>
</p>
)
}

98
src/hooks/useFetchCalendarRsvps.tsx

@ -1,25 +1,23 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { isCalendarEventKind } from '@/lib/calendar-event'
import {
calendarEventHexId,
calendarRsvpMatchesCalendarEvent,
parseCalendarRsvpStatus
} from '@/lib/calendar-rsvp-match'
import { import {
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { isCalendarEventKind } from '@/lib/calendar-event' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import client from '@/services/client.service' import client, { queryService } from '@/services/client.service'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' 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[] { function mergeRsvp(prev: Event[], evt: Event): Event[] {
const next = prev.filter((e) => e.id !== evt.id) const next = prev.filter((e) => e.id !== evt.id)
@ -38,11 +36,25 @@ function mergeRsvpList(events: Event[]): Event[] {
return acc return acc
} }
function filterMatchingRsvps(calendarEvent: Event, events: Event[]): Event[] {
return events.filter((ev) => calendarRsvpMatchesCalendarEvent(calendarEvent, ev))
}
export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const { relayList, cacheRelayListEvent } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const [rsvps, setRsvps] = useState<Event[]>([]) const [rsvps, setRsvps] = useState<Event[]>([])
const [isFetching, setIsFetching] = useState(false) 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(() => { useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) { if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) {
setRsvps([]) setRsvps([])
@ -52,34 +64,35 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
let cancelled = false let cancelled = false
setIsFetching(true) setIsFetching(true)
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const userRead = userReadInboxUrls(relayList, cacheRelayListEvent) const userRead = userReadInboxUrls(relayList, cacheRelayListEvent)
const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent) const userWrite = userWriteOutboxUrls(relayList, cacheRelayListEvent)
void (async () => { void (async () => {
const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) const fromSession = filterMatchingRsvps(
calendarEvent,
client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent)
)
setRsvps(mergeRsvpList(fromSession)) setRsvps(mergeRsvpList(fromSession))
const idbP = indexedDb const idbP = indexedDb
.getCalendarRsvpEventsByParentCoordinate(coordinate) .getCalendarRsvpEventsForCalendarEvent(calendarEvent)
.catch((): Event[] => []) .catch((): Event[] => [])
void idbP.then((rows) => { void idbP.then((rows) => {
if (cancelled) return if (cancelled) return
setRsvps(mergeRsvpList([...rows, ...fromSession])) setRsvps(mergeRsvpList(filterMatchingRsvps(calendarEvent, [...rows, ...fromSession])))
}) })
const baseUrls = new Set<string>([ const baseUrls = new Set<string>([
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...userRead.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[]) ].filter(Boolean) as string[])
const organizerPubkey = calendarEvent.pubkey const organizerPubkey = calendarEvent.pubkey
try { try {
let relayUrls: string[]
try { try {
const organizerRelays = await client.fetchRelayList(organizerPubkey) const organizerRelays = await client.fetchRelayList(organizerPubkey)
if (!cancelled) { if (!cancelled) {
@ -93,17 +106,17 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
if (u) baseUrls.add(u) if (u) baseUrls.add(u)
}) })
} }
relayUrls = Array.from(baseUrls)
} catch { } catch {
relayUrls = Array.from(baseUrls) // keep baseUrls
} }
if (cancelled) return if (cancelled) return
const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls)
const calendarHexId = /^[0-9a-f]{64}$/i.test(calendarEvent.id) const coordinate = normalizeReplaceableCoordinateString(
? calendarEvent.id.toLowerCase() getReplaceableCoordinateFromEvent(calendarEvent)
: calendarEvent.id )
const calendarHexId = calendarEventHexId(calendarEvent)
const events = await queryService.fetchEvents( const events = await queryService.fetchEvents(
urls, Array.from(baseUrls),
[ [
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
@ -123,12 +136,16 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
} }
) )
if (cancelled) return if (cancelled) return
const fromRelay = events ?? [] const fromRelay = filterMatchingRsvps(calendarEvent, events ?? [])
const fromIdb = await idbP const fromIdb = await idbP
await Promise.allSettled( await Promise.allSettled(
fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined)) fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined))
) )
setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...fromRelay])) setRsvps(
mergeRsvpList(
filterMatchingRsvps(calendarEvent, [...fromIdb, ...fromSession, ...fromRelay])
)
)
} finally { } finally {
if (!cancelled) setIsFetching(false) if (!cancelled) setIsFetching(false)
} }
@ -137,38 +154,23 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
return () => { return () => {
cancelled = true 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(() => { useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return 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 handler = (e: CustomEvent<Event>) => {
const evt = e.detail applyRsvp(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) client.addEventListener('newEvent', handler as EventListener)
return () => client.removeEventListener('newEvent', handler as EventListener) return () => client.removeEventListener('newEvent', handler as EventListener)
}, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey]) }, [calendarEvent, applyRsvp])
return { return {
rsvps, rsvps,
isFetching, isFetching,
getRsvpStatus getRsvpStatus: parseCalendarRsvpStatus,
applyRsvp
} }
} }

9
src/i18n/locales/de.ts

@ -842,6 +842,15 @@ export default {
'HTTP relays': 'HTTP-Relays', 'HTTP relays': 'HTTP-Relays',
httpRelaysDescription: 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.', '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', 'HTTP relays saved': 'HTTP-Relays gespeichert',
'Failed to save HTTP relay list': 'HTTP-Relay-Liste konnte nicht gespeichert werden', 'Failed to save HTTP relay list': 'HTTP-Relay-Liste konnte nicht gespeichert werden',
'HTTP relays must start with https:// or http://': 'HTTP relays must start with https:// or http://':

9
src/i18n/locales/en.ts

@ -862,6 +862,15 @@ export default {
'HTTP relays': 'HTTP relays', 'HTTP relays': 'HTTP relays',
httpRelaysDescription: 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.', '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', 'HTTP relays saved': 'HTTP relays saved',
'Failed to save HTTP relay list': 'Failed to save HTTP relay list', 'Failed to save HTTP relay list': 'Failed to save HTTP relay list',
'HTTP relays must start with https:// or http://': 'HTTP relays must start with https:// or http://':

105
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> = {}): 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('')
})
})

38
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}` : ''
}

22
src/pages/secondary/RelaySettingsPage/index.tsx

@ -3,6 +3,7 @@ import HttpRelaysSetting from '@/components/HttpRelaysSetting'
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import MailboxSetting from '@/components/MailboxSetting' import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import RelaySettingsKindNotice from '@/components/RelaySettingsKindNotice'
import SessionRelaysTab from '@/components/SessionRelaysTab' import SessionRelaysTab from '@/components/SessionRelaysTab'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -23,6 +24,12 @@ import { kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
@ -128,19 +135,24 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache Relays')}</TabsTrigger> <TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache Relays')}</TabsTrigger>
<TabsTrigger value="session-relays" className="w-full sm:w-auto">{t('Session relays')}</TabsTrigger> <TabsTrigger value="session-relays" className="w-full sm:w-auto">{t('Session relays')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="favorite-relays"> <TabsContent value="favorite-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={FAVORITE_TAB_KINDS} />
<FavoriteRelaysSetting /> <FavoriteRelaysSetting />
</TabsContent> </TabsContent>
<TabsContent value="mailbox"> <TabsContent value="mailbox" className="space-y-4">
<RelaySettingsKindNotice kinds={[kinds.RelayList]} />
<MailboxSetting /> <MailboxSetting />
</TabsContent> </TabsContent>
<TabsContent value="http-relays"> <TabsContent value="http-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={[ExtendedKind.HTTP_RELAY_LIST]} />
<HttpRelaysSetting /> <HttpRelaysSetting />
</TabsContent> </TabsContent>
<TabsContent value="cache-relays"> <TabsContent value="cache-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={[ExtendedKind.CACHE_RELAYS]} />
<CacheRelaysSetting /> <CacheRelaysSetting />
</TabsContent> </TabsContent>
<TabsContent value="session-relays"> <TabsContent value="session-relays" className="space-y-4">
<RelaySettingsKindNotice kinds={[]} variant="session" />
<SessionRelaysTab /> <SessionRelaysTab />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

18
src/services/client-events.service.ts

@ -47,6 +47,7 @@ import {
} from './event-archive.service' } from './event-archive.service'
import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event' import { isCalendarEventKind } from '@/lib/calendar-event'
import { calendarRsvpMatchesCalendarEvent } from '@/lib/calendar-rsvp-match'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter'
@ -1045,25 +1046,10 @@ export class EventService {
*/ */
getSessionCalendarRsvpsForCalendarEvent(calendarEvent: NEvent): NEvent[] { getSessionCalendarRsvpsForCalendarEvent(calendarEvent: NEvent): NEvent[] {
if (!isCalendarEventKind(calendarEvent.kind)) return [] 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[] = [] const out: NEvent[] = []
for (const [, event] of this.sessionEventCache.entries()) { for (const [, event] of this.sessionEventCache.entries()) {
if (event.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) continue
if (shouldDropEventOnIngest(event)) continue if (shouldDropEventOnIngest(event)) continue
const rawA = event.tags.find(tagNameEquals('a'))?.[1]?.trim() if (calendarRsvpMatchesCalendarEvent(calendarEvent, event)) out.push(event)
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)
}
} }
return out.sort((a, b) => b.created_at - a.created_at) return out.sort((a, b) => b.created_at - a.created_at)
} }

39
src/services/indexed-db.service.ts

@ -15,6 +15,7 @@ import {
getCalendarOccurrenceWindowMs, getCalendarOccurrenceWindowMs,
isCalendarEventKind isCalendarEventKind
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { calendarEventHexId, calendarRsvpParentKeyFromEventId } from '@/lib/calendar-rsvp-match'
import { import {
getReplaceableCoordinate, getReplaceableCoordinate,
getReplaceableCoordinateFromEvent, 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:<calendar-id>`. */
async putCalendarRsvpEventRow(ev: Event): Promise<void> { async putCalendarRsvpEventRow(ev: Event): Promise<void> {
if (ev.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return if (ev.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const rawA = ev.tags.find(tagNameEquals('a'))?.[1]?.trim() const rawA = ev.tags.find(tagNameEquals('a'))?.[1]?.trim()
if (!rawA) return const rawE = ev.tags.find(tagNameEquals('e'))?.[1]?.trim()
const parentCoordinate = normalizeReplaceableCoordinateString(rawA) 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 await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) return 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:<event-id>` rows. */
async getCalendarRsvpEventsForCalendarEvent(calendarEvent: Event, limit = 400): Promise<Event[]> {
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<string>()
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:<hex>`. */
async getCalendarRsvpEventsByParentCoordinate( async getCalendarRsvpEventsByParentCoordinate(
parentCoordinate: string, parentCoordinate: string,
limit = 400 limit = 400

Loading…
Cancel
Save