11 changed files with 336 additions and 78 deletions
@ -0,0 +1,60 @@
@@ -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 @@
@@ -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 @@
@@ -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