Publish and read RSVPs through the CalendarRsvp domain kind, and read calendar times and titles through TimeEvent

This commit is contained in:
Jon Staab 2026-09-25 15:59:54 -07:00
parent ba03406795
commit e7c7fac112
5 changed files with 62 additions and 72 deletions

View file

@ -6,15 +6,17 @@ import {
pushToMapKey,
secondsToDate,
sortBy,
spec,
uniqBy,
} from "@welshman/lib"
import {EVENT_RSVP, getAddress, makeEvent, tagSpec, tagValue} from "@welshman/util"
import {EVENT_RSVP, getAddress, tagSpec, tagValue} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {TimeEvent} from "@welshman/domain"
import {CalendarRsvp, TimeEvent} from "@welshman/domain"
import type {RsvpStatus} from "@welshman/domain"
import {synced} from "@welshman/store"
import {deletes, reader, relays, thunks} from "@app/core"
import {deletes, reader, relays, thunks, writer} from "@app/core"
import {deriveEvents} from "@app/repository"
import {PROTECTED} from "@app/rooms"
import {toSpace} from "@app/rooms"
import {kv} from "@app/storage"
// Views
@ -230,12 +232,6 @@ export const layoutMultiDayBars = (
// RSVPs (NIP-52)
export enum RsvpStatus {
Accepted = "accepted",
Declined = "declined",
Tentative = "tentative",
}
export const makeRsvpFilter = (event: TrustedEvent) => ({
kinds: [EVENT_RSVP],
"#a": [getAddress(event)],
@ -243,10 +239,7 @@ export const makeRsvpFilter = (event: TrustedEvent) => ({
export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter(event)])
export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags)
export const isAttending = (status?: string) =>
status === RsvpStatus.Accepted || status === RsvpStatus.Tentative
export const isAttending = (status?: RsvpStatus) => status === "accepted" || status === "tentative"
// A relay can still hold a replaced RSVP, so the newest per person is the one that counts.
export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => {
@ -256,37 +249,32 @@ export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => {
)
const byStatus = (status: RsvpStatus) =>
latest.filter(rsvp => getRsvpStatus(rsvp) === status).map(rsvp => rsvp.pubkey)
latest.filter(rsvp => reader(CalendarRsvp)(rsvp).status() === status).map(rsvp => rsvp.pubkey)
return {
latest,
accepted: byStatus(RsvpStatus.Accepted),
tentative: byStatus(RsvpStatus.Tentative),
declined: byStatus(RsvpStatus.Declined),
accepted: byStatus("accepted"),
tentative: byStatus("tentative"),
declined: byStatus("declined"),
}
}
// An RSVP is addressable, so identifying it by its target makes a new one supersede the old.
export const publishRsvp = async (url: string, event: TrustedEvent, status: RsvpStatus) => {
const address = getAddress(event)
const h = tagValue(tagSpec("h"), event.tags)
const tags = [
["a", address, url],
["e", event.id, url],
["d", address],
["status", status],
["p", event.pubkey],
]
const eventWriter = await toSpace(
writer(CalendarRsvp)
.setIdentifier(getAddress(event))
.setCalendarEventFromEvent(event)
.setStatus(status)
// The writer hints the organizer's outbox, but a space's event lives on the space relay.
.dropTags(spec(["a"]))
.dropTags(spec(["e"]))
.addTags(["a", getAddress(event), url], ["e", event.id, url]),
url,
reader(TimeEvent)(event).room(),
)
if (await relays.get().hasNip(url, 70)) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
return thunks.get().publish({relays: [url], event: makeEvent(EVENT_RSVP, {tags})})
return thunks.get().publish({relays: [url], event: await eventWriter.renderTemplate()})
}
export const retractRsvp = async (url: string, rsvp: TrustedEvent) => {

View file

@ -1,6 +1,8 @@
<script lang="ts">
import cx from "classnames"
import type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp} from "@welshman/domain"
import type {RsvpStatus} from "@welshman/domain"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import QuestionCircle from "@assets/icons/question-circle.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
@ -8,15 +10,8 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {user} from "@app/core"
import {
RsvpStatus,
getRsvpStatus,
getRsvpsByStatus,
isAttending,
publishRsvp,
retractRsvp,
} from "@app/calendar"
import {reader, user} from "@app/core"
import {getRsvpsByStatus, isAttending, publishRsvp, retractRsvp} from "@app/calendar"
import {setEventReminders} from "@app/reminders"
type Props = {
@ -35,7 +30,7 @@
loading = true
const retracting = ownRsvp && getRsvpStatus(ownRsvp) === status
const retracting = ownRsvp && ownStatus === status
try {
if (retracting) {
@ -50,11 +45,11 @@
}
}
const accept = () => setStatus(RsvpStatus.Accepted)
const accept = () => setStatus("accepted")
const maybe = () => setStatus(RsvpStatus.Tentative)
const maybe = () => setStatus("tentative")
const decline = () => setStatus(RsvpStatus.Declined)
const decline = () => setStatus("declined")
const statusClass = (status: RsvpStatus) =>
cx("button button-xs join-item", ownStatus === status ? "button-primary" : "button-neutral")
@ -63,7 +58,7 @@
const people = $derived(getRsvpsByStatus(rsvps))
const ownRsvp = $derived(people.latest.find(rsvp => rsvp.pubkey === $user.pubkey))
const ownStatus = $derived(ownRsvp ? getRsvpStatus(ownRsvp) : undefined)
const ownStatus = $derived(ownRsvp ? reader(CalendarRsvp)(ownRsvp).status() : undefined)
const attending = $derived([...people.accepted, ...people.tentative])
</script>
@ -71,15 +66,15 @@
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-75">Are you going?</span>
<div class="join">
<Button class={statusClass(RsvpStatus.Accepted)} disabled={loading} onclick={accept}>
<Button class={statusClass("accepted")} disabled={loading} onclick={accept}>
<Icon icon={CheckCircle} size={4} />
Going
</Button>
<Button class={statusClass(RsvpStatus.Tentative)} disabled={loading} onclick={maybe}>
<Button class={statusClass("tentative")} disabled={loading} onclick={maybe}>
<Icon icon={QuestionCircle} size={4} />
Maybe
</Button>
<Button class={statusClass(RsvpStatus.Declined)} disabled={loading} onclick={decline}>
<Button class={statusClass("declined")} disabled={loading} onclick={decline}>
<Icon icon={CloseCircle} size={4} />
Can't go
</Button>

View file

@ -15,10 +15,9 @@ import {
hexTags,
isReplaceableKind,
matchFilters,
tagSpec,
tagValue,
tagValues,
} from "@welshman/util"
import {TimeEvent} from "@welshman/domain"
import type {Maybe} from "@welshman/lib"
import type {Filter, TrustedEvent} from "@welshman/util"
import {mergeRepositoryUpdates} from "@welshman/net"
@ -27,7 +26,7 @@ import {createScroller} from "@lib/html"
import type {ScrollerOpts} from "@lib/html"
import {daysBetween} from "@lib/util"
import {EVENT_CONTEXT_KINDS, REACTION_KINDS} from "@app/content"
import {app, network} from "@app/core"
import {app, network, reader} from "@app/core"
import {getEventsForUrl} from "@app/repository"
const noEvents: TrustedEvent[] = []
@ -682,13 +681,17 @@ export const makeCalendarFeed = ({
let oldest = now()
let newest = now()
const getStart = (event: TrustedEvent) => parseInt(tagValue(tagSpec("start"), event.tags) || "")
const getEnd = (event: TrustedEvent) => parseInt(tagValue(tagSpec("end"), event.tags) || "")
const getStart = (event: TrustedEvent) => reader(TimeEvent)(event).start() ?? 0
const compareByStart = (a: TrustedEvent, b: TrustedEvent) =>
getStart(a) - getStart(b) || compareEventsAsc(a, b)
const isValid = (event: TrustedEvent) => {
const timeEvent = reader(TimeEvent)(event)
return timeEvent.start() && timeEvent.end() && !seen.has(event.id)
}
const events = writable(
uniqBy(
e => e.id,
@ -697,7 +700,7 @@ export const makeCalendarFeed = ({
)
const insertEvents = (newEvents: TrustedEvent[]) => {
const valid = newEvents.filter(e => !isNaN(getStart(e)) && !isNaN(getEnd(e)) && !seen.has(e.id))
const valid = newEvents.filter(isValid)
if (valid.length === 0) {
return

View file

@ -1,9 +1,10 @@
import {MINUTE, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib"
import {EVENT_RSVP, addressTags, getIdFilters, isSignedEvent, matchTag} from "@welshman/util"
import {MINUTE, first, formatTimestampRelative, int, ms, now, uniq} from "@welshman/lib"
import {EVENT_RSVP, getIdFilters, isSignedEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {CalendarRsvp} from "@welshman/domain"
import {buildUrl} from "@lib/util"
import {getEventStart, getEventTitle, getRsvpStatus, isAttending} from "@app/calendar"
import {app, network, roomLists} from "@app/core"
import {getEventStart, getEventTitle, isAttending} from "@app/calendar"
import {app, network, reader, roomLists} from "@app/core"
import {PUSH_SERVER} from "@app/env"
import {pushState} from "@app/push/adapters/common"
import {notificationSettings} from "@app/settings"
@ -70,14 +71,16 @@ const loadAttendedEvents = async (pubkey: string) => {
await $network.request({signal, filters, relays, autoClose: true})
const attended = app
.get()
.repository.query(filters)
.flatMap(rsvp => {
const tag = matchTag(addressTags("a"), rsvp.tags)
const {repository, tracker} = app.get()
return tag?.[2] && isAttending(getRsvpStatus(rsvp)) ? [{address: tag[1], relay: tag[2]}] : []
})
// An RSVP only goes to the space holding its event, so where it was seen is where the event is.
const attended = repository.query(filters).flatMap(rsvp => {
const rsvpReader = reader(CalendarRsvp)(rsvp)
const address = rsvpReader.calendarEvent()
const relay = first(Array.from(tracker.getRelays(rsvp.id)))
return address && relay && isAttending(rsvpReader.status()) ? [{address, relay}] : []
})
await $network.request({
signal,

View file

@ -3,6 +3,7 @@ import {tagSpec, tagValue} from "@welshman/util"
import {displayPubkey} from "@welshman/domain"
import {makeRoomKey} from "@welshman/app"
import {decodePubkey} from "@lib/util"
import {getEventTitle} from "@app/calendar"
import {app, profiles, rooms} from "@app/core"
import {PLATFORM_NAME} from "@app/env"
import {decodeRelay} from "@app/relays"
@ -137,7 +138,7 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
}
if (routeId === "/spaces/[relay]/calendar/[address]") {
return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Event")
return makeTitle((event && getEventTitle(event)) || "Event")
}
if (routeId === "/spaces/[relay]/classifieds/[address]") {