11 changed files with 336 additions and 78 deletions
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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('') |
||||||
|
}) |
||||||
|
}) |
||||||
@ -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}` : '' |
||||||
|
} |
||||||
Loading…
Reference in new issue